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,1428 @@
|
|
|
1
|
+
"""Native-Windows psmux backend for the terminal-multiplexer seam.
|
|
2
|
+
|
|
3
|
+
psmux (a Rust/ConPTY tmux re-implementation) speaks the tmux CLI through its
|
|
4
|
+
own distinctly-named ``psmux`` binary, so this leaf points the base's spawn
|
|
5
|
+
seam at that name, keeps every argv construction in :mod:`.tmux_base`, and
|
|
6
|
+
swaps only the shell dialect (PowerShell instead of POSIX sh) via the base's
|
|
7
|
+
hooks, plus the handful of behaviors where psmux diverges from tmux: an
|
|
8
|
+
attaching ``new-session`` is refused by a nesting guard when run from
|
|
9
|
+
inside a psmux pane, and a quoted command string does not survive psmux's
|
|
10
|
+
outer re-parse (so shell source travels as ``pwsh -EncodedCommand`` — the
|
|
11
|
+
log sink included, since the base's ``cat >>`` assumes a POSIX host
|
|
12
|
+
shell). Window ids are minted per server (one
|
|
13
|
+
server per session), so every id this backend hands back —
|
|
14
|
+
``new_window``, ``list_window_ids``, ``new_parked_window``, the
|
|
15
|
+
``window_id`` columns of ``list_windows``, and ``current_window_id`` — is
|
|
16
|
+
session-qualified to ``session:@N`` (degrading to the bare id only where
|
|
17
|
+
the grammar cannot carry the session name — see ``_qualified_window_id``)
|
|
18
|
+
and routes to the owning server from any caller (psmux/psmux#483). Every
|
|
19
|
+
seam verb takes that form, ``select-window`` included (psmux/psmux#497).
|
|
20
|
+
Per-window user options do not exist at all, so
|
|
21
|
+
the window-option verbs, the ``@``-prefixed columns of ``list_windows``
|
|
22
|
+
and the parked trailer route through a substitute channel — see the
|
|
23
|
+
``per-window option channel (#310)`` block below for the model and its
|
|
24
|
+
rules. Session-scoped options need no such substitute — one server per
|
|
25
|
+
session means that server's single map is the session's — but a value
|
|
26
|
+
still has to read back verbatim through the same listing parse, so
|
|
27
|
+
``set_session_option`` gates it on the same transportability rule (#320).
|
|
28
|
+
``detach-client`` and ``switch-client`` report dispatch, not effect: a
|
|
29
|
+
nonzero exit is a real failure, but a zero one says only that the verb was
|
|
30
|
+
sent, so neither seam boolean can be read off the exit code alone. The
|
|
31
|
+
session's attached-client count supplies what the rc cannot — as a drop
|
|
32
|
+
across ``detach-client`` and ``switch-client -l``, and as a gate on the
|
|
33
|
+
targeted ``switch-client -t``, whose same-session move no drop can see
|
|
34
|
+
(#659); see the ``client verbs: observed effect (#317)`` block. The seam's
|
|
35
|
+
``False`` is the joint claim that no switch happened *and* a client is still
|
|
36
|
+
here, so ``switch_client`` answers ``None`` wherever it cannot carry the
|
|
37
|
+
second half — for two different reasons. A timed-out verb and an rc-0 whose
|
|
38
|
+
gate count is unreadable are moves the server may have completed; a session
|
|
39
|
+
with nothing attached is a move it cannot have made, but there is nobody here
|
|
40
|
+
to keep prompting either. ``False`` is left to the exits that do carry the
|
|
41
|
+
whole claim, both of which dispatch nothing: no session to measure, and a
|
|
42
|
+
spawn fault with no fallback. psmux resolves every target
|
|
43
|
+
through a *registry* — the ``PSMUX_DATA_DIR`` directory of per-session
|
|
44
|
+
``.port``/``.key`` files — and froid-loop points it at a per-project root
|
|
45
|
+
(``runs.mux_registry_root``), so this backend owns the two rules that follow
|
|
46
|
+
from that: it refuses to spawn under a root psmux would panic on, and it can
|
|
47
|
+
mint an instance bound to psmux's own default registry so cleanup can still
|
|
48
|
+
address sessions created before the move (``legacy_registries``). Both live in
|
|
49
|
+
the ``_run`` override, the one place psmux is spawned.
|
|
50
|
+
``available()`` additionally gates on the
|
|
51
|
+
reported version —
|
|
52
|
+
see ``_LAST_UNSUPPORTED`` for what the floor buys and why it moves.
|
|
53
|
+
``has_session``
|
|
54
|
+
is inherited unchanged, but one server per session gives it a residual the
|
|
55
|
+
tmux path does not have: a ``-t`` read resolves through the named port and key
|
|
56
|
+
files, so a wrong ``True`` is reachable whenever that PAIR addresses some OTHER
|
|
57
|
+
live server. Both halves are needed — the server rejects a key mismatch before
|
|
58
|
+
running anything — so a merely recycled port does not reach it; a duplicated
|
|
59
|
+
registry entry or a same-named session on a foreign server does. A server that
|
|
60
|
+
is simply *gone* fails closed: measured on 3.3.8, both a removed port file and
|
|
61
|
+
a stale one whose process was killed answer rc 1, ``no server running on
|
|
62
|
+
session``. For the lost-session probe
|
|
63
|
+
(#489) that is the safe direction — a wrong ``True`` drops the diagnosis
|
|
64
|
+
rather than inventing one — and the collision it needs is an independently
|
|
65
|
+
created session sharing a ``froid-loop-<run-id>`` name: an operator's, or
|
|
66
|
+
another run's on a colliding id (see #531). The psmux
|
|
67
|
+
behaviors cited in this module were read from the psmux source at tag
|
|
68
|
+
``v3.3.8`` — the oldest build ``available()`` admits — and the safe
|
|
69
|
+
observable subset is probed in ``tests/test_psmux_live.py``.
|
|
70
|
+
See :mod:`.multiplexer` for the contract.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
from __future__ import annotations
|
|
74
|
+
|
|
75
|
+
import base64
|
|
76
|
+
import os
|
|
77
|
+
import re
|
|
78
|
+
import shlex
|
|
79
|
+
import shutil
|
|
80
|
+
import subprocess
|
|
81
|
+
import sys
|
|
82
|
+
from collections.abc import Mapping
|
|
83
|
+
from pathlib import Path
|
|
84
|
+
|
|
85
|
+
from .tmux_base import PARKED_RETURN_DETACH, BaseTmuxBackend, TmuxError
|
|
86
|
+
|
|
87
|
+
_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
88
|
+
# psmux's registry-root variable, as psmux spells it. `runs.PSMUX_DATA_DIR` holds
|
|
89
|
+
# the same name for the export side; the two are deliberately separate constants
|
|
90
|
+
# rather than one import, because the dependency only runs one way — `runs`
|
|
91
|
+
# imports the adapter seam, never the reverse — and a transport name belongs in
|
|
92
|
+
# the transport leaf.
|
|
93
|
+
_DATA_DIR = "PSMUX_DATA_DIR"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _pwsh_quote(value: str) -> str:
|
|
97
|
+
# A single-quoted PowerShell literal: no interpolation, and the only escape
|
|
98
|
+
# is doubling the quote itself.
|
|
99
|
+
return "'" + value.replace("'", "''") + "'"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# One warning per process about `PSMUX_BARE_ENV`, not per verb — see
|
|
103
|
+
# `PsmuxMultiplexer._warn_if_bare_env`.
|
|
104
|
+
_BARE_ENV_WARNED = False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _bare_env_on(value: str | None) -> bool:
|
|
108
|
+
"""psmux's own predicate for `PSMUX_BARE_ENV` (`src/pane.rs:890-892`,
|
|
109
|
+
source-read at v3.3.8): on iff the value is "1" or case-insensitively
|
|
110
|
+
"true"."""
|
|
111
|
+
return value is not None and (value == "1" or value.lower() == "true")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# The `PSMUX_DATA_DIR` that was in force before this process derived its own and
|
|
115
|
+
# overwrote it (`runs.export_psmux_registry_root`), or None when nothing was
|
|
116
|
+
# displaced. Process-local, and the *only* record of it: the variable itself is
|
|
117
|
+
# gone by the time anything asks. See `note_displaced_registry`.
|
|
118
|
+
_DISPLACED_ROOT: str | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def note_displaced_registry(value: str | None) -> None:
|
|
122
|
+
"""Record the registry root this process took over from, so the migration
|
|
123
|
+
sweep can still reach the sessions living in it.
|
|
124
|
+
|
|
125
|
+
Before #537 the backend simply inherited whatever ``PSMUX_DATA_DIR`` the
|
|
126
|
+
environment carried, so an operator who exported an absolute root of their
|
|
127
|
+
own has *their* pre-upgrade froid-loop sessions in THAT registry — not in
|
|
128
|
+
psmux's default. This process now overrides the variable, which makes those
|
|
129
|
+
sessions unaddressable by every later verb: `stop`, `attach` and `cleanup`
|
|
130
|
+
would each report nothing while the coding processes ran on. The default
|
|
131
|
+
registry cannot stand in for it; the two are different directories, and
|
|
132
|
+
which one a machine used is a fact only the displaced value carries.
|
|
133
|
+
|
|
134
|
+
First non-empty value wins, and a later call is ignored. The caller runs
|
|
135
|
+
once per process, ahead of dispatch (``cli._configure_mux``), so the first
|
|
136
|
+
value is the one the operator's environment actually had; a second call
|
|
137
|
+
would be handing back a root *this* process exported. Ceiling, named: a
|
|
138
|
+
single process that configured two different projects in turn would record
|
|
139
|
+
the first project's root as the second's displaced one. ``main()`` does not
|
|
140
|
+
do that, and the consequence if something ever did is a no-op sweep rather
|
|
141
|
+
than a hazard — the legacy pass demands this project's tag, which the other
|
|
142
|
+
project's sessions do not carry.
|
|
143
|
+
"""
|
|
144
|
+
global _DISPLACED_ROOT
|
|
145
|
+
if _DISPLACED_ROOT is None and value:
|
|
146
|
+
_DISPLACED_ROOT = value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class PsmuxMultiplexer(BaseTmuxBackend):
|
|
150
|
+
"""psmux backend — tmux-family argv from the base, PowerShell dialect and
|
|
151
|
+
the documented psmux divergences here.
|
|
152
|
+
|
|
153
|
+
Registered by :func:`~.multiplexer._load_builtin_backends` for ``win32``,
|
|
154
|
+
mirroring :class:`~.tmux_backend.TmuxMultiplexer`.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
# psmux ships psmux/pmux/tmux binaries built from the same source; spawning
|
|
158
|
+
# the distinct psmux name never collides with another tmux-family install
|
|
159
|
+
# (e.g. a tmux-windows port owning ``tmux`` on the same PATH).
|
|
160
|
+
_BINARY = "psmux"
|
|
161
|
+
# psmux emits UTF-8; decoding with the console codepage (cp1252) garbles
|
|
162
|
+
# format-string output, and a stray byte must degrade visibly, not raise.
|
|
163
|
+
_ENCODING = "utf-8"
|
|
164
|
+
_ERRORS = "backslashreplace"
|
|
165
|
+
|
|
166
|
+
def __init__(self, *, default_registry: bool = False, registry_root: str | None = None) -> None:
|
|
167
|
+
"""``default_registry=True`` binds this instance to psmux's OWN registry
|
|
168
|
+
root — the one it computes when ``PSMUX_DATA_DIR`` is unset — regardless
|
|
169
|
+
of what this process exports. That is the legacy registry every psmux
|
|
170
|
+
session froid-loop created before the per-project root existed still lives
|
|
171
|
+
in, and the only reason to build such an instance is to sweep it (see
|
|
172
|
+
:meth:`legacy_registries`).
|
|
173
|
+
|
|
174
|
+
``registry_root`` binds the instance to one NAMED root instead, for the
|
|
175
|
+
other pre-upgrade world: a machine whose operator exported an absolute
|
|
176
|
+
``PSMUX_DATA_DIR`` before the upgrade kept its froid-loop sessions there,
|
|
177
|
+
not in psmux's default (see :func:`note_displaced_registry`). Same
|
|
178
|
+
purpose, same single caller, and the same rule below about not touching
|
|
179
|
+
the process environment to do it.
|
|
180
|
+
|
|
181
|
+
The two are mutually exclusive and ``default_registry`` wins if both are
|
|
182
|
+
passed — a programming error either way, since a registry is one place.
|
|
183
|
+
|
|
184
|
+
Bound per instance rather than swapped into ``os.environ`` around a call:
|
|
185
|
+
the sweep runs on a TUI worker thread beside other threads issuing
|
|
186
|
+
ordinary verbs, and a global swap would aim one of *those* at the wrong
|
|
187
|
+
registry for as long as it was in place.
|
|
188
|
+
|
|
189
|
+
Unbinding is done by REMOVING the variable, never by re-deriving psmux's
|
|
190
|
+
default here: that default is ``<home>\\.psmux``, where the home is
|
|
191
|
+
``USERPROFILE`` when it is set and non-empty, then the profile API
|
|
192
|
+
(``GetUserProfileDirectoryW``), then ``HOMEDRIVE``+``HOMEPATH``, then
|
|
193
|
+
``HOME`` (``src/paths.rs`` ``home_dir``, source-read at v3.3.8) — and a
|
|
194
|
+
second spelling of that cascade in Python is a second thing to keep in
|
|
195
|
+
sync. A named ``registry_root`` is the opposite case and needs no
|
|
196
|
+
cascade: the value is the root, verbatim as the operator spelled it.
|
|
197
|
+
"""
|
|
198
|
+
self._default_registry = default_registry
|
|
199
|
+
self._registry_root = None if default_registry else registry_root
|
|
200
|
+
|
|
201
|
+
def _run(
|
|
202
|
+
self,
|
|
203
|
+
argv: list[str],
|
|
204
|
+
*,
|
|
205
|
+
check: bool = True,
|
|
206
|
+
env: dict[str, str] | None = None,
|
|
207
|
+
) -> subprocess.CompletedProcess[str]:
|
|
208
|
+
"""The base's one spawn point, plus this backend's registry-root rules.
|
|
209
|
+
|
|
210
|
+
Two things happen here and nowhere else, because this is the only place
|
|
211
|
+
psmux is spawned:
|
|
212
|
+
|
|
213
|
+
1. **Registry binding.** A ``default_registry`` instance spawns with
|
|
214
|
+
``PSMUX_DATA_DIR`` removed, so psmux computes its own root. The parent
|
|
215
|
+
env is *copied* first — a Windows child needs ``SystemRoot`` and
|
|
216
|
+
friends — and an explicit per-call ``env`` is honoured as the base, so
|
|
217
|
+
``new_session``'s scrubbed env keeps its scrubbing.
|
|
218
|
+
2. **The absoluteness gate.** psmux ``assert!``s ``PSMUX_DATA_DIR``
|
|
219
|
+
absolute and non-empty (``src/paths.rs``, source-read at v3.3.8) and
|
|
220
|
+
**panics** otherwise: a Rust panic message on stderr and a nonzero
|
|
221
|
+
exit, which ``has_session`` and friends read as an ordinary "no" —
|
|
222
|
+
a live session reading as gone, for a reason nothing in the output
|
|
223
|
+
names. Refusing here turns that into one froid-named error naming the
|
|
224
|
+
variable and its value. Checked on the *effective* env, so a per-call
|
|
225
|
+
``env`` carrying a bad value is caught too, not just the inherited one.
|
|
226
|
+
|
|
227
|
+
The unbound, well-formed case adds one dict lookup and no copy.
|
|
228
|
+
"""
|
|
229
|
+
effective = env if env is not None else os.environ
|
|
230
|
+
self._warn_if_bare_env(effective)
|
|
231
|
+
if self._default_registry:
|
|
232
|
+
env = {k: v for k, v in effective.items() if k != _DATA_DIR}
|
|
233
|
+
else:
|
|
234
|
+
if self._registry_root is not None:
|
|
235
|
+
# Bound instance: the root is set in the child's env, never in
|
|
236
|
+
# this process's — same threading rule as `default_registry`.
|
|
237
|
+
env = {**effective, _DATA_DIR: self._registry_root}
|
|
238
|
+
value = env.get(_DATA_DIR) if env is not None else effective.get(_DATA_DIR)
|
|
239
|
+
if value is not None and not (value and os.path.isabs(value)):
|
|
240
|
+
raise TmuxError(
|
|
241
|
+
f"{_DATA_DIR}={value!r} is not an absolute path; psmux panics on a "
|
|
242
|
+
"relative or empty registry root, so no verb can run under it — "
|
|
243
|
+
f"unset {_DATA_DIR} to use psmux's default registry, or set it to "
|
|
244
|
+
"an absolute directory"
|
|
245
|
+
)
|
|
246
|
+
return super()._run(argv, check=check, env=env)
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _warn_if_bare_env(effective: Mapping[str, str]) -> None:
|
|
250
|
+
"""Say once per process when ``PSMUX_BARE_ENV`` is on: froid-loop does
|
|
251
|
+
not support that mode, and what it breaks is quiet.
|
|
252
|
+
|
|
253
|
+
Under it psmux ``env_clear``s every pane child and repopulates from a
|
|
254
|
+
14-name allowlist (``src/pane.rs:889-908``, source-read at v3.3.8;
|
|
255
|
+
measured in a real pane) that drops ``FROID_LOOP_STATE_DIR`` *and* the
|
|
256
|
+
``LOCALAPPDATA`` its default cascade falls back to. Coding-CLI windows
|
|
257
|
+
still get the state root — their env rides the in-source
|
|
258
|
+
``-EncodedCommand`` prelude, which runs in the pane after the clear
|
|
259
|
+
(see ``_window_launch``) — but a session's window-0 shell and the TUI's
|
|
260
|
+
parked engine windows rely on inheritance, so a froid-loop run in one of
|
|
261
|
+
those re-derives the state root from what survived. That diverges, and
|
|
262
|
+
the run then reads this very session as gone, in exactly two cases:
|
|
263
|
+
``FROID_LOOP_STATE_DIR`` was in force (the clear drops it, and the
|
|
264
|
+
default cascade answers somewhere else), or ``LOCALAPPDATA`` names
|
|
265
|
+
something other than ``%USERPROFILE%\\AppData\\Local`` (a redirected
|
|
266
|
+
or roaming profile). ``USERPROFILE`` *is* on the allowlist, so on a
|
|
267
|
+
default profile the fallback arm lands on the same root and nothing
|
|
268
|
+
diverges — the mode is unsupported because the failure is silent when
|
|
269
|
+
it does happen, not because it always does. Supporting it means an env
|
|
270
|
+
transport on the session and parked-window verbs, which is its own
|
|
271
|
+
seam change — tracked
|
|
272
|
+
as a follow-up issue, deliberately outside #537.
|
|
273
|
+
|
|
274
|
+
Warned, not refused: the variable is psmux's (an operator may run their
|
|
275
|
+
own sessions under it), and most commands never open a window. Ceiling:
|
|
276
|
+
psmux reads the switch in the *server* process at pane spawn; this
|
|
277
|
+
process's effective env is a proxy for it, so a server already running
|
|
278
|
+
with the mode on under a clean client is not detected here.
|
|
279
|
+
"""
|
|
280
|
+
global _BARE_ENV_WARNED
|
|
281
|
+
if _BARE_ENV_WARNED or not _bare_env_on(effective.get("PSMUX_BARE_ENV")):
|
|
282
|
+
return
|
|
283
|
+
_BARE_ENV_WARNED = True
|
|
284
|
+
print(
|
|
285
|
+
"warning: PSMUX_BARE_ENV is on, which froid-loop does not support — "
|
|
286
|
+
"session and parked-window shells lose FROID_LOOP_STATE_DIR and derive "
|
|
287
|
+
"their own state root and registry, so a run can read as gone; unset "
|
|
288
|
+
"PSMUX_BARE_ENV for froid-loop's sessions",
|
|
289
|
+
file=sys.stderr,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def registry_root(self) -> str | None:
|
|
293
|
+
"""The registry root a verb from this instance inherits — the process
|
|
294
|
+
environment's, which is what ``_run``'s ``env=None`` default passes on.
|
|
295
|
+
|
|
296
|
+
A per-call ``env`` can still name another one; nothing reads this to
|
|
297
|
+
decide where a verb goes, only to disclose where they normally go.
|
|
298
|
+
|
|
299
|
+
A ``default_registry`` instance answers ``None`` — it spawns with the
|
|
300
|
+
variable *removed*, and the root psmux then computes for itself is
|
|
301
|
+
deliberately not respelled here (see :meth:`__init__`). ``None`` from
|
|
302
|
+
the ordinary instance likewise means psmux is on its own default
|
|
303
|
+
registry; :meth:`has_registry_namespace` is what separates either from
|
|
304
|
+
tmux's "no namespace exists", and a reader deciding ownership must use
|
|
305
|
+
it — the default registry is shared with every project and with the
|
|
306
|
+
operator, which is precisely what a ``None`` here must not be read as
|
|
307
|
+
disclaiming.
|
|
308
|
+
|
|
309
|
+
An instance bound to a named ``registry_root`` answers that root: it is
|
|
310
|
+
where its verbs actually go, and the environment's value is not.
|
|
311
|
+
"""
|
|
312
|
+
if self._default_registry:
|
|
313
|
+
return None
|
|
314
|
+
return self._registry_root or os.environ.get(_DATA_DIR)
|
|
315
|
+
|
|
316
|
+
def has_registry_namespace(self) -> bool:
|
|
317
|
+
# A property of the transport, not of the instance binding: even a
|
|
318
|
+
# `default_registry` instance addresses A registry — psmux's own.
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
def session_name_key(self, name: str) -> str:
|
|
322
|
+
"""psmux resolves a session by opening ``<data dir>\\<name>.port`` by
|
|
323
|
+
name (``src/paths.rs:113``, source-read at v3.3.8), and NTFS opens
|
|
324
|
+
names case-insensitively — measured: with ``froid-loop-ctl-x`` live,
|
|
325
|
+
target ``froid-loop-CTL-x`` answers ``has-session``, is refused as a
|
|
326
|
+
duplicate by ``new-session``, and a kill through it takes the
|
|
327
|
+
lowercase session down. So on this transport two names differing only
|
|
328
|
+
by ASCII case denote one session, and comparisons must fold."""
|
|
329
|
+
return name.lower()
|
|
330
|
+
|
|
331
|
+
def legacy_registries(self) -> list["PsmuxMultiplexer"]:
|
|
332
|
+
"""The registries a pre-upgrade session of ours may still be living in:
|
|
333
|
+
psmux's default, and the root this process displaced.
|
|
334
|
+
|
|
335
|
+
Sessions froid-loop created before the per-project root existed are still
|
|
336
|
+
there, and after the move nothing else can address them: cleanup would
|
|
337
|
+
report a clean sweep while their servers ran on. An extra pass over each
|
|
338
|
+
is the whole migration, and an already-migrated machine just finds
|
|
339
|
+
nothing in either.
|
|
340
|
+
|
|
341
|
+
**Two of them, because there were two pre-upgrade worlds.** The old
|
|
342
|
+
backend inherited whatever ``PSMUX_DATA_DIR`` the process carried, so a
|
|
343
|
+
machine that never set it kept its sessions in psmux's *default* root,
|
|
344
|
+
while one whose operator exported an absolute root of their own kept
|
|
345
|
+
them THERE. Returning only the default assumed the first machine and
|
|
346
|
+
left the second's sessions — and their live coding processes —
|
|
347
|
+
unreachable by every verb, with cleanup reporting success. The displaced
|
|
348
|
+
root is remembered by :func:`note_displaced_registry`, because the
|
|
349
|
+
variable no longer holds it.
|
|
350
|
+
|
|
351
|
+
Neither pass gets extra reach from being here: ``prune_sessions`` runs
|
|
352
|
+
every legacy registry with ``require_tag=True``, so a session is claimed
|
|
353
|
+
only on its own ownership tag and never on run-directory evidence, which
|
|
354
|
+
proves nothing in a registry shared with other projects and with the
|
|
355
|
+
operator's own psmux sessions. That is what keeps this off a by-name
|
|
356
|
+
kill in somebody else's registry.
|
|
357
|
+
|
|
358
|
+
The default-registry pass is skipped when ``PSMUX_DATA_DIR`` is unset
|
|
359
|
+
(this process IS on the default registry — the primary pass already
|
|
360
|
+
covers it) and when it is set to a value psmux would panic on, where the
|
|
361
|
+
primary pass is not running either and a sweep would be the only thing
|
|
362
|
+
that appeared to work. The displaced pass is skipped when nothing was
|
|
363
|
+
displaced, when the displaced value is one psmux would panic on, and
|
|
364
|
+
when it is the root in force (nothing moved). A displaced value that
|
|
365
|
+
happens to spell psmux's own default is swept twice, harmlessly:
|
|
366
|
+
``prune_sessions`` unions its passes by run id, so one session cannot
|
|
367
|
+
become two kills.
|
|
368
|
+
"""
|
|
369
|
+
value = os.environ.get(_DATA_DIR)
|
|
370
|
+
registries: list[PsmuxMultiplexer] = []
|
|
371
|
+
if value and os.path.isabs(value):
|
|
372
|
+
registries.append(type(self)(default_registry=True))
|
|
373
|
+
displaced = _DISPLACED_ROOT
|
|
374
|
+
if displaced and os.path.isabs(displaced) and displaced != value:
|
|
375
|
+
registries.append(type(self)(registry_root=displaced))
|
|
376
|
+
return registries
|
|
377
|
+
|
|
378
|
+
# ------------------------------------------- shell dialect (PowerShell)
|
|
379
|
+
|
|
380
|
+
# A command pwsh could not even start (not recognized) still runs the rest of
|
|
381
|
+
# the source but leaves $LASTEXITCODE unset — coalesce with a plain `if`
|
|
382
|
+
# (works on any PowerShell version) rather than the PS7-only `??` syntax.
|
|
383
|
+
_EXIT_CAPTURE = "$ec = if ($null -eq $LASTEXITCODE) { 1 } else { $LASTEXITCODE }"
|
|
384
|
+
_ECHO = "Write-Host"
|
|
385
|
+
_PARK = "Read-Host"
|
|
386
|
+
|
|
387
|
+
def _join_argv(self, argv: list[str]) -> str:
|
|
388
|
+
# The call operator runs a quoted executable with quoted args verbatim.
|
|
389
|
+
# A bare `& ` is a pwsh parse error, so refuse an empty argv here rather
|
|
390
|
+
# than shipping a window that dies on launch.
|
|
391
|
+
if not argv:
|
|
392
|
+
raise TmuxError("empty command")
|
|
393
|
+
return "& " + " ".join(_pwsh_quote(arg) for arg in argv)
|
|
394
|
+
|
|
395
|
+
def _source_prefix(self) -> str:
|
|
396
|
+
# psmux windows inherit the claude environment of whichever process
|
|
397
|
+
# cold-started the psmux server (teammate mode, session ids, SSE ports).
|
|
398
|
+
# Clear it so a CLI launched here starts fresh instead of impersonating
|
|
399
|
+
# that session.
|
|
400
|
+
return (
|
|
401
|
+
"Get-ChildItem Env: | Where-Object { $_.Name -like 'CLAUDE_CODE_*' "
|
|
402
|
+
"-or $_.Name -like 'CLAUDECODE*' -or $_.Name -eq 'PSMUX_CLAUDE_TEAMMATE_MODE' } "
|
|
403
|
+
"| ForEach-Object { Remove-Item ('Env:' + $_.Name) }; "
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def _shell_wrap(self, source: str) -> list[str]:
|
|
407
|
+
# psmux joins the trailing argv and re-parses it through an outer shell,
|
|
408
|
+
# which strips embedded quoting; -EncodedCommand (base64 of UTF-16LE) is
|
|
409
|
+
# the lossless transport for arbitrary shell source.
|
|
410
|
+
encoded = base64.b64encode(source.encode("utf-16-le")).decode("ascii")
|
|
411
|
+
return ["pwsh", "-NoProfile", "-EncodedCommand", encoded]
|
|
412
|
+
|
|
413
|
+
def _parked_trailer(self, return_opt: str) -> str:
|
|
414
|
+
# The base's trailer re-expressed in pwsh — the tmux verbs are protocol-
|
|
415
|
+
# identical across the family. Errors go to $null: a client or pane that
|
|
416
|
+
# is already gone means the window just parks as-is.
|
|
417
|
+
#
|
|
418
|
+
# The base reads the return target with `show-options -wqv`, which is
|
|
419
|
+
# dead on psmux (#310), so this reads the session-scoped id-keyed option
|
|
420
|
+
# instead. Unlike every other caller of that channel, the trailer cannot
|
|
421
|
+
# be handed its own window id — it is built before the window exists —
|
|
422
|
+
# so it probes for it in-pane. `-t $env:TMUX_PANE` pins the probe to the
|
|
423
|
+
# pane's own window (psmux sets TMUX_PANE in every pane, and a bare `%N`
|
|
424
|
+
# target resolves globally via DisplayMessageById); a target-less probe
|
|
425
|
+
# would resolve the server's *active* window, which is another window's
|
|
426
|
+
# key the moment focus moves after Enter. No `-t <session>`: running
|
|
427
|
+
# inside the pane, $TMUX already routes to this window's own server.
|
|
428
|
+
#
|
|
429
|
+
# Both captures go through "$(...)".Trim(): a bare capture yields an
|
|
430
|
+
# array when psmux emits more than one line, and `-eq` on an array
|
|
431
|
+
# filters instead of comparing — silently taking neither branch.
|
|
432
|
+
mux = self._BINARY
|
|
433
|
+
probe = (
|
|
434
|
+
'"$(' + mux + " display-message -p -t $env:TMUX_PANE '#{window_id}' 2>$null)\".Trim()"
|
|
435
|
+
)
|
|
436
|
+
read_key = '"$(' + mux + ' show-options -qv $key 2>$null)".Trim()'
|
|
437
|
+
return (
|
|
438
|
+
f"$wid = {probe}; "
|
|
439
|
+
# A failed probe skips the return AND the key free; the orphan
|
|
440
|
+
# sweep at a later parked-window launch reclaims the key once the
|
|
441
|
+
# window is gone.
|
|
442
|
+
# $wid arrives as `@N` — it supplies _SCOPE_MARKER's trailing `@`.
|
|
443
|
+
f"if ($wid) {{ $key = {_pwsh_quote(return_opt + self._SCOPE_MARKER[:-1])} + $wid; "
|
|
444
|
+
f"$ret = {read_key}; "
|
|
445
|
+
f"if ($ret -eq '{PARKED_RETURN_DETACH}') {{ {mux} detach-client 2>$null }} "
|
|
446
|
+
# The switch leg's target routes on the supported build — the
|
|
447
|
+
# server parses it rather than splitting it raw (psmux/psmux#483) —
|
|
448
|
+
# so this is no longer the dead branch it was; whether a real client
|
|
449
|
+
# actually lands there is an attended-console question, not one any
|
|
450
|
+
# unattended gate here can answer.
|
|
451
|
+
f"elseif ($ret) {{ {mux} switch-client -t $ret 2>$null; "
|
|
452
|
+
f"if ($LASTEXITCODE -ne 0) {{ {mux} switch-client -l 2>$null }} }} "
|
|
453
|
+
# Free the key on the way out: the ctl session outlives every run,
|
|
454
|
+
# so a key left behind is one this server carries for its whole life.
|
|
455
|
+
f"{mux} set-option -u $key 2>$null }}"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def _window_launch(self, env: dict[str, str], command: str) -> list[str]:
|
|
459
|
+
# 3.3.8 delivers `new-window -e`, but the env keeps riding an in-source
|
|
460
|
+
# prelude: this leaf's command already travels as `-EncodedCommand`
|
|
461
|
+
# source that must run the CLAUDE_* scrub (_source_prefix) in-pane
|
|
462
|
+
# anyway, so the prelude is the transport already present — `-e` flags
|
|
463
|
+
# would be a second one whose values the scrub then has to be ordered
|
|
464
|
+
# against. The prelude also survives `PSMUX_BARE_ENV=1` (measured both
|
|
465
|
+
# ways on 3.3.8: an in-source assignment runs in the pane after the
|
|
466
|
+
# allowlist clear), which is why a coding-CLI window keeps its
|
|
467
|
+
# `FROID_LOOP_STATE_DIR` pin even under that mode — see the bare-env
|
|
468
|
+
# warning in `_run` for what does not. `command` arrives POSIX-quoted
|
|
469
|
+
# (callers shlex-quote each arg), so split it here and re-quote for pwsh.
|
|
470
|
+
for key in env:
|
|
471
|
+
if not _ENV_NAME.fullmatch(key):
|
|
472
|
+
raise TmuxError(f"invalid environment variable name: {key!r}")
|
|
473
|
+
try:
|
|
474
|
+
argv = shlex.split(command)
|
|
475
|
+
except ValueError as exc:
|
|
476
|
+
raise TmuxError(f"unparseable command: {exc}") from exc
|
|
477
|
+
prelude = "".join(f"$env:{key} = {_pwsh_quote(value)}; " for key, value in env.items())
|
|
478
|
+
source = self._source_prefix() + prelude + self._join_argv(argv)
|
|
479
|
+
return self._shell_wrap(source)
|
|
480
|
+
|
|
481
|
+
# ------------------------------------------------- psmux divergences
|
|
482
|
+
|
|
483
|
+
def new_session(
|
|
484
|
+
self, name: str, cwd: Path, cols: int | None = None, lines: int | None = None
|
|
485
|
+
) -> None:
|
|
486
|
+
# psmux's nesting guard refuses new-session from inside a psmux pane
|
|
487
|
+
# (current builds only for an attaching one, older builds no-op'd `-d`
|
|
488
|
+
# too — exit 0, nothing created); the documented bypass is one env var
|
|
489
|
+
# on the create call, kept as a cheap belt. The create env copies the
|
|
490
|
+
# parent env rather than building from scratch — Windows children need
|
|
491
|
+
# SystemRoot etc. — but scrubs the claude session vars (the same names
|
|
492
|
+
# _source_prefix clears per window): this call may cold-start the psmux
|
|
493
|
+
# server, whose env every window then inherits.
|
|
494
|
+
env = {
|
|
495
|
+
k: v
|
|
496
|
+
for k, v in os.environ.items()
|
|
497
|
+
if not (
|
|
498
|
+
k.upper().startswith(("CLAUDE_CODE_", "CLAUDECODE"))
|
|
499
|
+
or k.upper() == "PSMUX_CLAUDE_TEAMMATE_MODE"
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
env["PSMUX_ALLOW_NESTING"] = "1"
|
|
503
|
+
geometry = ["-x", str(cols), "-y", str(lines)] if cols and lines else []
|
|
504
|
+
try:
|
|
505
|
+
proc = self._run(
|
|
506
|
+
["new-session", "-d", "-s", name, "-c", str(cwd), *geometry],
|
|
507
|
+
check=False,
|
|
508
|
+
env=env,
|
|
509
|
+
)
|
|
510
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
511
|
+
raise TmuxError(f"{self._BINARY} new-session failed: {exc}") from exc
|
|
512
|
+
if proc.returncode != 0:
|
|
513
|
+
raise TmuxError(f"{self._BINARY} new-session failed: {proc.stderr.strip()}")
|
|
514
|
+
# Belt for the nesting guard's historical no-op mode (exit 0, nothing
|
|
515
|
+
# created): verify the session exists so the failure blames session
|
|
516
|
+
# creation, not the next verb's "can't find session".
|
|
517
|
+
if not self.has_session(name):
|
|
518
|
+
raise TmuxError(
|
|
519
|
+
f"{self._BINARY} new-session exited 0 but session {name!r} was not "
|
|
520
|
+
"created (nesting guard no-op?)"
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
@staticmethod
|
|
524
|
+
def _qualified_window_id(session: str, window_id: str) -> str:
|
|
525
|
+
# psmux mints window ids per server (one server per session), so a bare
|
|
526
|
+
# `@N` replayed as a `-t` target routes by the caller's $TMUX — from a
|
|
527
|
+
# ctl pane that is the wrong server entirely (#254). A `session:@N`
|
|
528
|
+
# target routes by the session's port file instead (psmux/psmux#483:
|
|
529
|
+
# qualified targets are at full parity; bare ids are a permanent model
|
|
530
|
+
# boundary). Degrade to the bare id when the session name is empty or
|
|
531
|
+
# contains `:` (the grammar cannot carry it: `":@N"` parses sessionless
|
|
532
|
+
# and `a:b:@N` splits at the wrong colon) — the #221 rule — or when the
|
|
533
|
+
# id is falsy (a failure sentinel must pass through unchanged).
|
|
534
|
+
#
|
|
535
|
+
# Composed here rather than via self.target() — which would emit
|
|
536
|
+
# `=session:@N`, and does parse (psmux strips the `=`) — because the
|
|
537
|
+
# seam requires target() to stay a stable *by-name* token and `@N` is
|
|
538
|
+
# an id. Do not "unify" the two grammars: current_return_target's
|
|
539
|
+
# `=session:%N` is a target(), this is not.
|
|
540
|
+
if not window_id or not session or ":" in session:
|
|
541
|
+
return window_id
|
|
542
|
+
return f"{session}:{window_id}"
|
|
543
|
+
|
|
544
|
+
def new_window(
|
|
545
|
+
self, session: str, name: str, cwd: Path, env: dict[str, str], command: str
|
|
546
|
+
) -> str:
|
|
547
|
+
# Session-qualify the minted id (see _qualified_window_id) so every
|
|
548
|
+
# downstream `-t` consumer inherits an unambiguous target. Post-process
|
|
549
|
+
# the base's return rather than reformatting `-F` so the argv stays
|
|
550
|
+
# byte-identical to the base's.
|
|
551
|
+
window_id = super().new_window(session, name, cwd, env, command)
|
|
552
|
+
return self._qualified_window_id(session, window_id)
|
|
553
|
+
|
|
554
|
+
def list_window_ids(self, session: str) -> list[str]:
|
|
555
|
+
# psmux's list-windows emits bare `@N` lines; qualify them identically
|
|
556
|
+
# to new_window or window_alive's membership check (native_id in
|
|
557
|
+
# list_window_ids) would read every window as dead.
|
|
558
|
+
return [
|
|
559
|
+
self._qualified_window_id(session, window_id)
|
|
560
|
+
for window_id in super().list_window_ids(session)
|
|
561
|
+
]
|
|
562
|
+
|
|
563
|
+
def new_parked_window(
|
|
564
|
+
self, session: str, name: str, cwd: Path, argv: list[str], return_opt: str
|
|
565
|
+
) -> str:
|
|
566
|
+
# Same per-server ambiguity as new_window, worse context: the launcher
|
|
567
|
+
# usually runs OUTSIDE any pane, so a bare `@N` replayed as a `-t` target
|
|
568
|
+
# falls through to the most-recent-session fallback rather than the
|
|
569
|
+
# session that just minted it.
|
|
570
|
+
window_id = super().new_parked_window(session, name, cwd, argv, return_opt)
|
|
571
|
+
# Launch time is the reconcile point for keys whose window is gone —
|
|
572
|
+
# Enter-dismissing a parked window closes it without kill_window ever
|
|
573
|
+
# running, so its keys would otherwise outlive it (#310).
|
|
574
|
+
self._sweep_orphan_keys(session)
|
|
575
|
+
return self._qualified_window_id(session, window_id)
|
|
576
|
+
|
|
577
|
+
def list_windows(self, session: str, fields: list[str]) -> list[tuple[str, ...]]:
|
|
578
|
+
# Two corrections on the columns the prune reads. `window_id` is
|
|
579
|
+
# qualified — the prune replays those values as kill-window targets, and
|
|
580
|
+
# a bare one can hit another server's identically-numbered window. An
|
|
581
|
+
# `@` field never reaches psmux: `#{@name}` expands from the one
|
|
582
|
+
# per-server map, so every row would carry the same value. That is a
|
|
583
|
+
# WINDOW-row problem only — one server holds many windows but exactly
|
|
584
|
+
# one session, so the same expansion in session_options' list-sessions
|
|
585
|
+
# is correct by construction (see set_session_option). Probe the
|
|
586
|
+
# window id in its place and fill from the id-keyed options, fetched as
|
|
587
|
+
# ONE full listing — a flat extra call per list_windows, regardless of
|
|
588
|
+
# row or column count (#310).
|
|
589
|
+
opt_columns = {i: field for i, field in enumerate(fields) if field.startswith("@")}
|
|
590
|
+
probe_fields = [
|
|
591
|
+
"window_id" if i in opt_columns else field for i, field in enumerate(fields)
|
|
592
|
+
]
|
|
593
|
+
rows = super().list_windows(session, probe_fields)
|
|
594
|
+
id_columns = {i for i, field in enumerate(fields) if field == "window_id"}
|
|
595
|
+
if not opt_columns and not id_columns:
|
|
596
|
+
return rows
|
|
597
|
+
# The #221 degrade: an empty or `:`-bearing session cannot be routed
|
|
598
|
+
# with `-t`, and an unrouted read would answer from whichever server
|
|
599
|
+
# the fallback picks — fill "" without issuing reads at all.
|
|
600
|
+
degraded = not session or ":" in session
|
|
601
|
+
options = self._scoped_options(session) if opt_columns and not degraded else None
|
|
602
|
+
if opt_columns and not degraded and options is None:
|
|
603
|
+
# Say it: every column degrades to "unset" at once, so a prune reads
|
|
604
|
+
# as if nothing were ever tagged. Visible on the CLI prune (cli.py,
|
|
605
|
+
# incl. the --dry-run this most affects); NOT under the TUI, which
|
|
606
|
+
# captures stderr for the app's whole run (tui/app.py's run_tui
|
|
607
|
+
# note) — a deliberate ceiling, as with every warning in this module.
|
|
608
|
+
print(
|
|
609
|
+
f"warning: show-options listing failed on {session}; option columns read as unset",
|
|
610
|
+
file=sys.stderr,
|
|
611
|
+
)
|
|
612
|
+
out: list[tuple[str, ...]] = []
|
|
613
|
+
for row in rows:
|
|
614
|
+
values = list(row)
|
|
615
|
+
for i, option in opt_columns.items():
|
|
616
|
+
# values[i] is the bare `@N` probed in the option column's place.
|
|
617
|
+
digits = self._id_digits(values[i])
|
|
618
|
+
if degraded or not digits or options is None:
|
|
619
|
+
# Unreadable degrades to "" ("unset") rather than to a
|
|
620
|
+
# "read failed" sentinel: the caller's untagged fallback
|
|
621
|
+
# proves ownership on its own (the ctl prune claims an
|
|
622
|
+
# untagged window only when the run dir exists under THIS
|
|
623
|
+
# project, and run ids are unique — runs.new_run_id), so a
|
|
624
|
+
# third state in the column would buy only the skip of our
|
|
625
|
+
# own dead window.
|
|
626
|
+
values[i] = ""
|
|
627
|
+
else:
|
|
628
|
+
values[i] = options.get(self._scoped_option_key(option, digits), "")
|
|
629
|
+
for i in id_columns:
|
|
630
|
+
values[i] = self._qualified_window_id(session, values[i])
|
|
631
|
+
out.append(tuple(values))
|
|
632
|
+
return out
|
|
633
|
+
|
|
634
|
+
# ------------------------------------ per-window option channel (#310)
|
|
635
|
+
#
|
|
636
|
+
# Per-window user options do not exist on psmux: there is one scope per
|
|
637
|
+
# server, and every `-w` read of an `@` name returns '' before the map is
|
|
638
|
+
# consulted (a 14-name builtin allowlist gates it). Deliberate, per
|
|
639
|
+
# psmux/psmux#321 — a boundary to route around, not a bug to wait out.
|
|
640
|
+
#
|
|
641
|
+
# Substitute: a SESSION-scoped option whose key carries the window id
|
|
642
|
+
# (`@froid_project__blw@3` for `@3`). Two rules keep it correct —
|
|
643
|
+
# - `-t <session>` on every out-of-pane write and read (the parked
|
|
644
|
+
# trailer runs in-pane and rides `$TMUX` instead — see
|
|
645
|
+
# `_parked_trailer`). psmux picks the server from the target, and
|
|
646
|
+
# without an explicit session it falls back to $TMUX / most-recent —
|
|
647
|
+
# i.e. some other server. The session comes from the qualified ids
|
|
648
|
+
# this backend already mints, which is why #310 lands after #291.
|
|
649
|
+
# - the key carries the seam-owned `__blw@` marker plus the full id
|
|
650
|
+
# (`__blw@3`, never bare digits): the ctl server loads the user's
|
|
651
|
+
# psmux config, so the map is shared, and the generic cleanup sweeps
|
|
652
|
+
# delete every key matching their suffix. No real-world naming
|
|
653
|
+
# convention collides with the marker — a user option only matches by
|
|
654
|
+
# deliberately imitating the seam (`@theme__blw@3` would; the old
|
|
655
|
+
# hazard shapes `@theme_@3` / `@color_3` cannot) — and it keeps caller
|
|
656
|
+
# names out of the backend (a froid-owned prefix guard would hardcode
|
|
657
|
+
# them). The session stays out of the key; routing already carries it.
|
|
658
|
+
#
|
|
659
|
+
# Builtin window options keep the base's `-w` argv — psmux accepts those
|
|
660
|
+
# allowlisted names (only `automatic-rename` has true per-window storage;
|
|
661
|
+
# the rest read the global value), and rerouting them into this channel
|
|
662
|
+
# would break reads that work today.
|
|
663
|
+
|
|
664
|
+
# A window target as the seam composes it: `session:@N`, `=session:@N`, or
|
|
665
|
+
# `=session:<window-name>`. Deliberately looser than _QUALIFIED_ID, which
|
|
666
|
+
# only admits the id form — the seam's option/kill verbs accept name
|
|
667
|
+
# tokens even though today's launch.py callers all pass ids.
|
|
668
|
+
_SESSION_WINDOW = re.compile(r"^(?P<session>[^:]+):(?P<window>.+)$")
|
|
669
|
+
|
|
670
|
+
@staticmethod
|
|
671
|
+
def _id_digits(window_id: str) -> str:
|
|
672
|
+
"""`@3` -> `3`; `""` for anything that is not a bare window id."""
|
|
673
|
+
return window_id[1:] if PsmuxMultiplexer._BARE_ID.fullmatch(window_id) else ""
|
|
674
|
+
|
|
675
|
+
def _option_scope(self, target: str) -> tuple[str, str] | None:
|
|
676
|
+
"""``(session, id-digits)`` for a window target, or None when it carries
|
|
677
|
+
no session and so cannot be routed to a specific server.
|
|
678
|
+
|
|
679
|
+
A sessionless target reaches here only where `_qualified_window_id`
|
|
680
|
+
degraded. Guessing a server for it is the misrouting qualification
|
|
681
|
+
exists to prevent, so it returns None and the caller declines to act.
|
|
682
|
+
"""
|
|
683
|
+
match = self._SESSION_WINDOW.fullmatch(target.removeprefix("="))
|
|
684
|
+
if match is None:
|
|
685
|
+
return None
|
|
686
|
+
session, window = match["session"], match["window"]
|
|
687
|
+
digits = self._id_digits(window)
|
|
688
|
+
if not digits:
|
|
689
|
+
# A name token costs one listing round-trip, and psmux can rename or
|
|
690
|
+
# renumber between it and the verb using the answer. Best-effort
|
|
691
|
+
# callers, one round-trip wide, so no lock.
|
|
692
|
+
digits = self._id_digits(self._window_id_for_name(session, window) or "")
|
|
693
|
+
return (session, digits) if digits else None
|
|
694
|
+
|
|
695
|
+
def _window_id_for_name(self, session: str, name: str) -> str | None:
|
|
696
|
+
"""Bare window id of the window called ``name`` in ``session``, or None."""
|
|
697
|
+
# super(): the base emits unqualified ids, the form the key needs.
|
|
698
|
+
for win_id, win_name in super().list_windows(session, ["window_id", "window_name"]):
|
|
699
|
+
if win_name == name:
|
|
700
|
+
return win_id
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
# The seam-owned key marker ("froid-loop window"): every key this channel
|
|
704
|
+
# mints ends in `<marker><digits>`, and both cleanup sweeps match keys by
|
|
705
|
+
# it — keep the mint (_scoped_option_key, _parked_trailer) and the matchers
|
|
706
|
+
# (_KEY_SUFFIX, kill_window) derived from this one literal (within this
|
|
707
|
+
# class: _KEY_SUFFIX binds at class-body time and _scoped_option_key is
|
|
708
|
+
# static, so a subclass rebinding the marker would split mint from match).
|
|
709
|
+
#
|
|
710
|
+
# No transition rule for the pre-marker `_@<digits>` keys, deliberately
|
|
711
|
+
# (#313 floated one): a sweep matching the old shape would delete the very
|
|
712
|
+
# `@theme_@3` the marker exists to protect. They just read as foreign. The
|
|
713
|
+
# channel is unreleased, so only a dev build holds any, and the cost falls
|
|
714
|
+
# on windows parked BEFORE the upgrade: their trailer is baked in at mint
|
|
715
|
+
# (tmux_base.new_parked_window) and still reads the old key, so their tag
|
|
716
|
+
# reads unset (the prune falls back to the run dir) and their return move
|
|
717
|
+
# stops firing. Restarting the ctl server clears the map.
|
|
718
|
+
_SCOPE_MARKER = "__blw@"
|
|
719
|
+
|
|
720
|
+
@staticmethod
|
|
721
|
+
def _scoped_option_key(option: str, digits: str) -> str:
|
|
722
|
+
return f"{option}{PsmuxMultiplexer._SCOPE_MARKER}{digits}"
|
|
723
|
+
|
|
724
|
+
# The channel's key suffix. Anchored: `@froid_project__blw@13` must not read
|
|
725
|
+
# as a key of window `@3`, and a key without the marker — `@color_3`, a
|
|
726
|
+
# hand-written `@theme_@3` — never matches (see the namespace note in the
|
|
727
|
+
# channel comment above).
|
|
728
|
+
_KEY_SUFFIX = re.compile(re.escape(_SCOPE_MARKER) + r"(\d+)$")
|
|
729
|
+
|
|
730
|
+
def _read_scoped(self, session: str, option: str, digits: str) -> str | None:
|
|
731
|
+
# No `-w`: the session-scoped read is the one that reaches the map.
|
|
732
|
+
# None = transport failure, "" = unset. The ABC read admits only "", so
|
|
733
|
+
# the caller collapses them — the distinction survives as a warning.
|
|
734
|
+
try:
|
|
735
|
+
proc = self._run(
|
|
736
|
+
["show-options", "-qv", "-t", session, self._scoped_option_key(option, digits)],
|
|
737
|
+
check=False,
|
|
738
|
+
)
|
|
739
|
+
except (subprocess.SubprocessError, OSError):
|
|
740
|
+
return None
|
|
741
|
+
return proc.stdout.strip() if proc.returncode == 0 else None
|
|
742
|
+
|
|
743
|
+
def _warn_unroutable(self, verb: str, target: str, option: str) -> None:
|
|
744
|
+
# Silence would leave a write that looks like it landed but did not.
|
|
745
|
+
print(
|
|
746
|
+
f"warning: {verb} {option} skipped — {target} does not resolve to a "
|
|
747
|
+
"window id on a routable session (unqualified target, unknown window "
|
|
748
|
+
"name, or the name-resolve listing failed), so the per-window option "
|
|
749
|
+
"channel is unavailable",
|
|
750
|
+
file=sys.stderr,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
@staticmethod
|
|
754
|
+
def _transportable(value: str) -> bool:
|
|
755
|
+
# A value that cannot make the round trip verbatim is refused loudly
|
|
756
|
+
# instead of stored corrupted — a tag that reads back different from
|
|
757
|
+
# what the prune compares against makes the window silently unprunable.
|
|
758
|
+
#
|
|
759
|
+
# 3.3.8 carries the WIRE half whole: the client's quoting, the one-shot
|
|
760
|
+
# chain splitter and the server tokenizer no longer eat `\\`, a trailing
|
|
761
|
+
# `\`, `;`/`\;` tokens, a bare `'` or non-ASCII whitespace
|
|
762
|
+
# (psmux/psmux#547, #499, #536), so ordinary Windows paths — spaced,
|
|
763
|
+
# UNC, apostrophed, trailing-separator — all pass now.
|
|
764
|
+
#
|
|
765
|
+
# What still fails is the READ half, which is ours, not psmux's:
|
|
766
|
+
# `_scoped_options` iterates `splitlines()` and strips ONE surrounding
|
|
767
|
+
# `"` pair, and both reads `.strip()`/`.Trim()`. So a `"`, any line
|
|
768
|
+
# break, and leading/trailing whitespace stay refused however cleanly
|
|
769
|
+
# the wire now carries them — a value that reads back different is the
|
|
770
|
+
# same silently-unprunable window whichever hop mangled it. An empty
|
|
771
|
+
# value is a silent server-side no-op (`-u` is the verb for that), and a
|
|
772
|
+
# `-`-leading one is still dropped as a flag server-side, or flips the
|
|
773
|
+
# key to unset at rc 0 (psmux/psmux#583, open upstream).
|
|
774
|
+
if not value or value.startswith("-"):
|
|
775
|
+
return False
|
|
776
|
+
if '"' in value or value != value.strip():
|
|
777
|
+
return False
|
|
778
|
+
return value.splitlines() == [value]
|
|
779
|
+
|
|
780
|
+
def set_session_option(self, name: str, option: str, value: str) -> None:
|
|
781
|
+
# Session scope itself needs no substitute channel: psmux serves one
|
|
782
|
+
# session per server, so that server's single option map IS the
|
|
783
|
+
# session's map — the same model that makes per-window options unusable
|
|
784
|
+
# makes session options correct by construction (probed: two sessions
|
|
785
|
+
# on two servers read back their own values). What it does share with
|
|
786
|
+
# the window channel is a value that must survive the write AND this
|
|
787
|
+
# backend's own read, and this write was ungated (#320). A corrupted
|
|
788
|
+
# tag is non-empty and never equals the caller's tag again, so the
|
|
789
|
+
# prune skips that session forever.
|
|
790
|
+
#
|
|
791
|
+
# Refusing leaves the option unset. Project ownership now uses a hex
|
|
792
|
+
# digest that clears this gate by construction (#419), but the gate stays
|
|
793
|
+
# as the general contract for every `@` session option.
|
|
794
|
+
#
|
|
795
|
+
# The refusal frees the key rather than just returning. A session this
|
|
796
|
+
# backend just created is NOT a blank map — the server loads the user's
|
|
797
|
+
# psmux config (same shared-map basis as the option-channel block
|
|
798
|
+
# below), so this name can arrive pre-seeded. Leaving a foreign value
|
|
799
|
+
# in place would read back as a real non-matching tag and strand the
|
|
800
|
+
# session forever, which is exactly the failure the gate exists to stop.
|
|
801
|
+
if option.startswith("@") and not self._transportable(value):
|
|
802
|
+
print(
|
|
803
|
+
f"warning: set-option {option} skipped on session {name} — value does "
|
|
804
|
+
"not survive psmux's control-line transport verbatim; the key is freed "
|
|
805
|
+
"and the option reads as unset",
|
|
806
|
+
file=sys.stderr,
|
|
807
|
+
)
|
|
808
|
+
self._write_scoped(["set-option", "-u", "-t", name, option], option)
|
|
809
|
+
return
|
|
810
|
+
super().set_session_option(name, option, value)
|
|
811
|
+
|
|
812
|
+
def _write_scoped(self, verb: list[str], key: str) -> None:
|
|
813
|
+
# Both window-channel mutating verbs, one body — plus the session-tag
|
|
814
|
+
# refusal's free above, which wants the same never-raise contract for
|
|
815
|
+
# the same reason. A write that silently failed re-opens the mis-scoped
|
|
816
|
+
# prune this channel exists to close, and a silently failed `-u` leaves
|
|
817
|
+
# a live return key that replays the return move when the window's
|
|
818
|
+
# command exits (or, at session scope, a pre-seeded foreign tag that
|
|
819
|
+
# strands the session) — so failures are said out loud either way (the
|
|
820
|
+
# verbs stay best-effort: warn, never raise).
|
|
821
|
+
label = " ".join(verb[: verb.index("-t")]) # `set-option` / `set-option -u`
|
|
822
|
+
try:
|
|
823
|
+
proc = self._run(verb, check=False)
|
|
824
|
+
if proc.returncode != 0:
|
|
825
|
+
print(f"warning: {label} {key} failed: {proc.stderr.strip()}", file=sys.stderr)
|
|
826
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
827
|
+
print(f"warning: {label} {key} failed: {exc}", file=sys.stderr)
|
|
828
|
+
|
|
829
|
+
def set_window_option(self, target: str, option: str, value: str) -> None:
|
|
830
|
+
if not option.startswith("@"):
|
|
831
|
+
super().set_window_option(target, option, value)
|
|
832
|
+
return
|
|
833
|
+
# Scope first, transport second: an unroutable target has nothing to
|
|
834
|
+
# write AND nothing to free, and resolving here keeps the refusal path
|
|
835
|
+
# from re-entering unset_window_option and warning twice about a verb
|
|
836
|
+
# the caller never issued.
|
|
837
|
+
scope = self._option_scope(target)
|
|
838
|
+
if scope is None:
|
|
839
|
+
self._warn_unroutable("set-option", target, option)
|
|
840
|
+
return
|
|
841
|
+
session, digits = scope
|
|
842
|
+
key = self._scoped_option_key(option, digits)
|
|
843
|
+
if not self._transportable(value):
|
|
844
|
+
print(
|
|
845
|
+
f"warning: set-option {option} skipped — value does not survive "
|
|
846
|
+
"psmux's control-line transport verbatim; the key is freed and "
|
|
847
|
+
"the option reads as unset",
|
|
848
|
+
file=sys.stderr,
|
|
849
|
+
)
|
|
850
|
+
# Free any prior value: a refused REwrite must not leave the stale
|
|
851
|
+
# one to be replayed later (e.g. a parked return target).
|
|
852
|
+
self._write_scoped(["set-option", "-u", "-t", session, key], key)
|
|
853
|
+
return
|
|
854
|
+
self._write_scoped(["set-option", "-t", session, key, value], key)
|
|
855
|
+
|
|
856
|
+
def unset_window_option(self, target: str, option: str) -> None:
|
|
857
|
+
if not option.startswith("@"):
|
|
858
|
+
super().unset_window_option(target, option)
|
|
859
|
+
return
|
|
860
|
+
scope = self._option_scope(target)
|
|
861
|
+
if scope is None:
|
|
862
|
+
self._warn_unroutable("set-option -u", target, option)
|
|
863
|
+
return
|
|
864
|
+
session, digits = scope
|
|
865
|
+
# `-u` genuinely frees the key: the server's SetOptionUnset handler
|
|
866
|
+
# removes `@`-prefixed names from the map (verified at v3.3.8).
|
|
867
|
+
key = self._scoped_option_key(option, digits)
|
|
868
|
+
self._write_scoped(["set-option", "-u", "-t", session, key], key)
|
|
869
|
+
|
|
870
|
+
def show_window_option(self, target: str, option: str) -> str:
|
|
871
|
+
if not option.startswith("@"):
|
|
872
|
+
return super().show_window_option(target, option)
|
|
873
|
+
scope = self._option_scope(target)
|
|
874
|
+
if scope is None:
|
|
875
|
+
# An unroutable target warns for reads the same as for writes —
|
|
876
|
+
# no verb is sent; "" already means "unset" to every caller.
|
|
877
|
+
self._warn_unroutable("show-options", target, option)
|
|
878
|
+
return ""
|
|
879
|
+
value = self._read_scoped(scope[0], option, scope[1])
|
|
880
|
+
if value is None:
|
|
881
|
+
# Transport failure, not a miss. The return value still degrades
|
|
882
|
+
# to "" ("unset") — that is all the ABC read admits — but say so.
|
|
883
|
+
print(
|
|
884
|
+
f"warning: show-options {option} failed on {target}; treating as unset",
|
|
885
|
+
file=sys.stderr,
|
|
886
|
+
)
|
|
887
|
+
return ""
|
|
888
|
+
return value
|
|
889
|
+
|
|
890
|
+
def _scoped_options(self, session: str) -> dict[str, str] | None:
|
|
891
|
+
"""All `@`-prefixed options on ``session`` as ``{key: value}``, or None
|
|
892
|
+
on any failure (distinct from {} — an empty map is a real answer).
|
|
893
|
+
Live-verified on 3.3.8: `show-options -q -t <session>` lists user
|
|
894
|
+
options as `@key "value"` lines. This parse is what bounds
|
|
895
|
+
_transportable: it strips ONE surrounding `"` pair and iterates
|
|
896
|
+
`splitlines()`, so a `"` or a line break is refused at the WRITE and the
|
|
897
|
+
strip stays lossless for every value this backend stored. Known hole:
|
|
898
|
+
one server request handler (the ``ShowOptions`` arm of
|
|
899
|
+
``drain_plugin_req`` in server/mod.rs) answers this listing
|
|
900
|
+
empty-with-success while keys exist, so a
|
|
901
|
+
surprising {} is possible and is not proof that no keys are set."""
|
|
902
|
+
try:
|
|
903
|
+
proc = self._run(["show-options", "-q", "-t", session], check=False)
|
|
904
|
+
except (subprocess.SubprocessError, OSError):
|
|
905
|
+
return None
|
|
906
|
+
if proc.returncode != 0:
|
|
907
|
+
return None
|
|
908
|
+
options: dict[str, str] = {}
|
|
909
|
+
for line in proc.stdout.splitlines():
|
|
910
|
+
name, _, rest = line.partition(" ")
|
|
911
|
+
if not name.startswith("@"):
|
|
912
|
+
continue
|
|
913
|
+
if len(rest) >= 2 and rest.startswith('"') and rest.endswith('"'):
|
|
914
|
+
rest = rest[1:-1]
|
|
915
|
+
options[name] = rest
|
|
916
|
+
return options
|
|
917
|
+
|
|
918
|
+
def _free_scoped_key(self, session: str, name: str) -> None:
|
|
919
|
+
# The one unset path both sweeps share, routed through the same warn-
|
|
920
|
+
# never-raise body as every other write. rc-0 is not proof the key is
|
|
921
|
+
# gone on psmux's write side, but a nonzero rc IS proof it is not —
|
|
922
|
+
# silence here would leak a key for the server's life with no signal.
|
|
923
|
+
# Delegating also contains a dead round-trip to its own key: the outer
|
|
924
|
+
# guard in either sweep would otherwise catch it and abandon the keys
|
|
925
|
+
# after it in the batch.
|
|
926
|
+
self._write_scoped(["set-option", "-u", "-t", session, name], f"{name} on {session}")
|
|
927
|
+
|
|
928
|
+
def _sweep_orphan_keys(self, session: str) -> None:
|
|
929
|
+
# Free seam-minted (`__blw@N`) keys whose window no longer exists
|
|
930
|
+
# (Enter-dismissed parked windows never pass through kill_window). The
|
|
931
|
+
# marker keeps every foreign config option out of the sweep; window ids
|
|
932
|
+
# are never recycled within a server, so a swept key cannot belong to a
|
|
933
|
+
# future window.
|
|
934
|
+
#
|
|
935
|
+
# Order matters: keys are snapshotted BEFORE the live-window listing, so
|
|
936
|
+
# a window minted-and-tagged between the two calls has its key outside
|
|
937
|
+
# the snapshot and cannot be swept as a false orphan.
|
|
938
|
+
try:
|
|
939
|
+
options = self._scoped_options(session)
|
|
940
|
+
if options is None:
|
|
941
|
+
# Transport failure, not "no keys": a sweep that silently fails
|
|
942
|
+
# every launch leaks keys with no signal anywhere.
|
|
943
|
+
print(
|
|
944
|
+
f"warning: orphan-key sweep on {session} could not list "
|
|
945
|
+
"options; orphaned keys unswept until the next launch",
|
|
946
|
+
file=sys.stderr,
|
|
947
|
+
)
|
|
948
|
+
return
|
|
949
|
+
live = set(super().list_window_ids(session)) # base = bare ids
|
|
950
|
+
if not live:
|
|
951
|
+
# A session being swept just minted a window, so an empty live
|
|
952
|
+
# list is a failed probe, not an empty session — treating it as
|
|
953
|
+
# truth would sweep every key, live windows included. Warned for
|
|
954
|
+
# the same reason the listing failure above is, and this is the
|
|
955
|
+
# branch that actually fires: list_window_ids RAISES on a
|
|
956
|
+
# transport fault (caught below) and answers [] only on rc != 0,
|
|
957
|
+
# so silence here is a server failing every launch with no signal.
|
|
958
|
+
print(
|
|
959
|
+
f"warning: orphan-key sweep on {session} could not list live "
|
|
960
|
+
"windows; orphaned keys unswept until the next launch",
|
|
961
|
+
file=sys.stderr,
|
|
962
|
+
)
|
|
963
|
+
return
|
|
964
|
+
for name in options:
|
|
965
|
+
match = self._KEY_SUFFIX.search(name)
|
|
966
|
+
if match and f"@{match.group(1)}" not in live:
|
|
967
|
+
self._free_scoped_key(session, name)
|
|
968
|
+
except (subprocess.SubprocessError, OSError, TmuxError) as exc:
|
|
969
|
+
# Reconcile is opportunistic; the mint must never fail on it. But a
|
|
970
|
+
# sweep that fails every launch leaks keys for the server's whole
|
|
971
|
+
# life, so the failure is at least visible.
|
|
972
|
+
print(f"warning: orphan-key sweep failed on {session}: {exc}", file=sys.stderr)
|
|
973
|
+
|
|
974
|
+
def kill_window(self, target: str) -> None:
|
|
975
|
+
# Kill first, clean only once the window is verifiably gone: the ctl
|
|
976
|
+
# session outlives every run, so a leaked key lives as long as the
|
|
977
|
+
# server — but a kill that FAILS must leave the live window its keys
|
|
978
|
+
# (the project tag scopes the prune retry; the return key keeps both
|
|
979
|
+
# return legs armed, see _parked_trailer). Scope resolves before the kill because a name
|
|
980
|
+
# token cannot be resolved once the window is dead. An empty liveness
|
|
981
|
+
# listing is ambiguous — a failed probe, or a session that died with
|
|
982
|
+
# its last window — so it degrades toward retaining the keys; the
|
|
983
|
+
# launch-time orphan sweep reclaims them once the window is provably
|
|
984
|
+
# gone. Discovery is generic by the seam's marker — the backend must
|
|
985
|
+
# not know which option names callers use. Best-effort throughout:
|
|
986
|
+
# cleanup failure warns (the sweep precedent) but never blocks or
|
|
987
|
+
# fails the kill.
|
|
988
|
+
# Cost, accepted: two listing round-trips per kill, three for a name
|
|
989
|
+
# target (name-resolve, liveness, then keys; agent-window kills pay it
|
|
990
|
+
# too, for nothing — and prune_ctl_windows fans it out once per stale
|
|
991
|
+
# window), plus one more from the base's survivor probe whenever the
|
|
992
|
+
# kill exits non-zero — on psmux that includes ordinary window-death
|
|
993
|
+
# teardown; skip-by-session-name if that ever measures.
|
|
994
|
+
scope = self._option_scope(target)
|
|
995
|
+
super().kill_window(target)
|
|
996
|
+
if scope is None:
|
|
997
|
+
return
|
|
998
|
+
session, digits = scope
|
|
999
|
+
try:
|
|
1000
|
+
live = super().list_window_ids(session) # base = bare ids
|
|
1001
|
+
if not live or f"@{digits}" in live:
|
|
1002
|
+
return
|
|
1003
|
+
options = self._scoped_options(session)
|
|
1004
|
+
if options is None:
|
|
1005
|
+
# Transport failure, not "no keys" — the keys of a verified-
|
|
1006
|
+
# dead window are now stranded until the orphan sweep.
|
|
1007
|
+
print(
|
|
1008
|
+
f"warning: kill-window key cleanup on {session} could not "
|
|
1009
|
+
"list options; stranded keys await the orphan sweep",
|
|
1010
|
+
file=sys.stderr,
|
|
1011
|
+
)
|
|
1012
|
+
return
|
|
1013
|
+
suffix = f"{self._SCOPE_MARKER}{digits}"
|
|
1014
|
+
for name in options:
|
|
1015
|
+
if name.endswith(suffix):
|
|
1016
|
+
self._free_scoped_key(session, name)
|
|
1017
|
+
except (subprocess.SubprocessError, OSError, TmuxError) as exc:
|
|
1018
|
+
print(f"warning: kill-window key cleanup failed on {session}: {exc}", file=sys.stderr)
|
|
1019
|
+
|
|
1020
|
+
def _display_message(self, fmt: str) -> str | None:
|
|
1021
|
+
# The base's target-less probe resolves the server's *active* window on
|
|
1022
|
+
# psmux, so every inherited probe (current_window_id / current_pane_id /
|
|
1023
|
+
# current_session / current_return_target) answers for a foreign window
|
|
1024
|
+
# whenever this process's window is not the focused one (gh-669): the
|
|
1025
|
+
# ctl prune's own-window exclusion lands on the wrong window, and the
|
|
1026
|
+
# attach return records a pane the human never came from. Pin the probe
|
|
1027
|
+
# to the calling pane, exactly as _parked_trailer already does in pwsh:
|
|
1028
|
+
# psmux sets TMUX_PANE in every pane, and a bare `%N` target resolves
|
|
1029
|
+
# globally via DisplayMessageById. Still a probe rather than a
|
|
1030
|
+
# short-circuit to the env value — the round trip also verifies the
|
|
1031
|
+
# pane is live (a dead id exits nonzero, hence None). TMUX guard first,
|
|
1032
|
+
# as in the base; with TMUX set but TMUX_PANE unset the probe is
|
|
1033
|
+
# unpinnable, and an unpinnable probe must not answer for a foreign
|
|
1034
|
+
# window — None without spawning.
|
|
1035
|
+
if not os.environ.get("TMUX"):
|
|
1036
|
+
return None
|
|
1037
|
+
pane = os.environ.get("TMUX_PANE")
|
|
1038
|
+
if not pane:
|
|
1039
|
+
return None
|
|
1040
|
+
# Measured on 3.3.8: an `@N`-shaped or live-session-name value in the
|
|
1041
|
+
# target slot resolves rc=0 to the ACTIVE window — the foreign answer
|
|
1042
|
+
# this override exists to eliminate — while plain garbage fails closed
|
|
1043
|
+
# at rc!=0. Only a pane-shaped value may reach `-t`.
|
|
1044
|
+
if not re.fullmatch(r"%\d+", pane):
|
|
1045
|
+
return None
|
|
1046
|
+
try:
|
|
1047
|
+
proc = self._run(["display-message", "-p", "-t", pane, fmt], check=False)
|
|
1048
|
+
except (subprocess.SubprocessError, OSError):
|
|
1049
|
+
return None
|
|
1050
|
+
return proc.stdout.strip() if proc.returncode == 0 else None
|
|
1051
|
+
|
|
1052
|
+
# What _qualified_window_id composes: `<session>:@<n>`. The session part
|
|
1053
|
+
# excludes `:` because that is exactly when qualification degrades to a bare
|
|
1054
|
+
# id; requiring `@<digits>` keeps a `=session:window-name` token out — which
|
|
1055
|
+
# is what makes it the shape current_window_id must probe back into, so the
|
|
1056
|
+
# prune's own-window compare lines up against list_windows' rows.
|
|
1057
|
+
_QUALIFIED_ID = re.compile(r"^(?P<session>[^:]+):(?P<window_id>@\d+)$")
|
|
1058
|
+
_BARE_ID = re.compile(r"^@\d+$")
|
|
1059
|
+
|
|
1060
|
+
def current_window_id(self) -> str | None:
|
|
1061
|
+
# Must match list_windows' window_id form: the ctl prune skips its own
|
|
1062
|
+
# window by comparing them, so a mismatch makes a prune run from inside a
|
|
1063
|
+
# ctl window kill that window. Hence ONE probe rather than a window id
|
|
1064
|
+
# plus a separate session name — a split probe can resolve the id and
|
|
1065
|
+
# fail the session, and rows are qualified from the session list_windows
|
|
1066
|
+
# was *passed*, so neither a bare id nor None could ever equal one.
|
|
1067
|
+
probed = self._display_message("#{session_name}:#{window_id}")
|
|
1068
|
+
if not probed:
|
|
1069
|
+
return None
|
|
1070
|
+
if self._QUALIFIED_ID.fullmatch(probed):
|
|
1071
|
+
return probed
|
|
1072
|
+
# A `:`-bearing (or empty) session name can't carry the grammar — the
|
|
1073
|
+
# #221 degrade, which list_windows applies to its rows identically. A
|
|
1074
|
+
# malformed id is no target at all; half-parsing one would aim a kill.
|
|
1075
|
+
_, _, window_id = probed.rpartition(":")
|
|
1076
|
+
return window_id if self._BARE_ID.fullmatch(window_id) else None
|
|
1077
|
+
|
|
1078
|
+
def current_return_target(self) -> str | None:
|
|
1079
|
+
# psmux runs one server per session, so a bare pane id recorded on a
|
|
1080
|
+
# control-session window is session-local at replay: at best
|
|
1081
|
+
# unresolvable, at worst colliding with a real control-session pane and
|
|
1082
|
+
# landing the client on the wrong one with exit 0 (psmux/psmux#483).
|
|
1083
|
+
# Qualify with the session: psmux's parse_target accepts a pane id in
|
|
1084
|
+
# the window slot of `=session:%N` and (on releases carrying the #483
|
|
1085
|
+
# fix) forwards it to the owning server, where the id resolves in
|
|
1086
|
+
# exactly the right per-server id space. tmux must NOT receive this
|
|
1087
|
+
# form — its window resolver has no pane-id handling, so the qualified
|
|
1088
|
+
# target errors and the return degrades to `switch-client -l` — which
|
|
1089
|
+
# is why composition is backend-owned rather than seam-uniform.
|
|
1090
|
+
pane = self.current_pane_id()
|
|
1091
|
+
if not pane:
|
|
1092
|
+
return None
|
|
1093
|
+
session = self.current_session()
|
|
1094
|
+
# Degrade to the bare id (the base default, pre-#483 hazard and all)
|
|
1095
|
+
# rather than None when the session probe fails, answers empty, or
|
|
1096
|
+
# answers a name the `=session:%N` grammar cannot carry: a resolvable
|
|
1097
|
+
# own pane means we ARE inside the multiplexer, so recording "detach"
|
|
1098
|
+
# would strand the client.
|
|
1099
|
+
if not session or ":" in session:
|
|
1100
|
+
return pane
|
|
1101
|
+
return self.target(session, pane)
|
|
1102
|
+
|
|
1103
|
+
# ------------------------------- client verbs: observed effect (#317)
|
|
1104
|
+
#
|
|
1105
|
+
# psmux's client verbs report *dispatch*, not effect, and the seam's contract
|
|
1106
|
+
# is effect: an unobservable move must never answer a vacuous True. WHAT it
|
|
1107
|
+
# answers instead is per verb — False from detach_client, None from
|
|
1108
|
+
# switch_client, whose False is the stronger joint claim that a client is
|
|
1109
|
+
# still HERE (see TerminalMultiplexer.switch_client).
|
|
1110
|
+
# The exit code is trustworthy in ONE direction for every verb — a nonzero
|
|
1111
|
+
# one is a real failure (an unreachable session server, a target that would
|
|
1112
|
+
# not parse) — so the verdict source is per verb, and there are two of them.
|
|
1113
|
+
#
|
|
1114
|
+
# ``switch-client -t`` reads the exit code, gated on this session having had
|
|
1115
|
+
# a client to move; an rc-0 the gate cannot vouch for answers None, never
|
|
1116
|
+
# False — the move may well have happened. The gate is what a bare rc
|
|
1117
|
+
# cannot supply: the verb still
|
|
1118
|
+
# exits 0 with nothing attached (pinned by
|
|
1119
|
+
# test_premise_client_verbs_exit_zero_with_no_client_to_move), which is the
|
|
1120
|
+
# rc-0 no-op (#228) reached through Python. With a client present the rc is
|
|
1121
|
+
# the whole answer, because the target either resolved server-side and moved
|
|
1122
|
+
# it (psmux/psmux#483) or failed loudly — pinned in the failure direction by
|
|
1123
|
+
# test_adopted_switch_client_rejects_an_unresolvable_target, and measured with
|
|
1124
|
+
# a real attached client only by hand (no CI box has one).
|
|
1125
|
+
#
|
|
1126
|
+
# Two costs are taken deliberately here, since both are the kind that go
|
|
1127
|
+
# unnoticed. The gate IS an absolute count, which the delta rule below
|
|
1128
|
+
# forbids for good reason: a misrouted read (#315) can hand back a FOREIGN
|
|
1129
|
+
# session's nonzero count, and a switch that exits 0 for its own reasons
|
|
1130
|
+
# would then read as vouched-for. _attached_clients now refuses the answer
|
|
1131
|
+
# that does not NAME the session it was asked about, so what is left is a
|
|
1132
|
+
# foreign server whose session carries the same name — the #531 collision,
|
|
1133
|
+
# which no read of a name can separate. It is admitted only because the
|
|
1134
|
+
# alternative — the delta — is blind to the same-session move this verb's
|
|
1135
|
+
# main caller performs, and the damage directions are not equal: a wrong
|
|
1136
|
+
# True here costs one unverified hand-back claim, where the delta cost a
|
|
1137
|
+
# relocated human. And the rc rule
|
|
1138
|
+
# assumes the admitted floor: psmux/psmux#483 lands in 3.3.8, so on a build
|
|
1139
|
+
# forced past ``available()`` (an explicit backend override) a ``-t`` that
|
|
1140
|
+
# moves nobody still exits 0 and is answered True.
|
|
1141
|
+
#
|
|
1142
|
+
# ``switch-client -l`` and ``detach-client`` read the attached-client DELTA:
|
|
1143
|
+
# count the clients on this session before and after, and answer on the drop.
|
|
1144
|
+
# Neither can use rc — ``-l`` has no target form and carries no server reply
|
|
1145
|
+
# at all, and ``detach-client`` exits 0 with zero clients attached (a
|
|
1146
|
+
# flag-less detach is promoted server-side to detach-all). Never an absolute
|
|
1147
|
+
# count: a misrouted read answers for a session nobody asked about, so "zero
|
|
1148
|
+
# attached" on its own proves nothing (#315) — and the identity compare in
|
|
1149
|
+
# _attached_clients narrows that to a same-name foreign server rather than
|
|
1150
|
+
# closing it. Against a DIFFERENTLY-named server the delta is additionally
|
|
1151
|
+
# safe: manufacturing a drop needs two successful reads, and the second one
|
|
1152
|
+
# is refused by name. Against a same-NAME one it is not — that server's own
|
|
1153
|
+
# client detaching between the two reads is a real drop, of a real client,
|
|
1154
|
+
# in the wrong session. #531 is the only thing that closes it.
|
|
1155
|
+
#
|
|
1156
|
+
# The delta is why ``-t`` could not stay on it (#659): a switch whose target
|
|
1157
|
+
# lives in THIS session moves the client between windows without changing the
|
|
1158
|
+
# session's count, so a real move read as no effect. That was not merely a
|
|
1159
|
+
# conservative False. With ``last_fallback`` set — which is exactly how
|
|
1160
|
+
# ``tui.launch.return_attached_client`` calls it — the failed verdict fired
|
|
1161
|
+
# ``-l``, which dragged the client out to an unrelated session and produced
|
|
1162
|
+
# the delta the correct move never could, so the call returned True and the
|
|
1163
|
+
# caller cleared its return option. Measured on a live attended client:
|
|
1164
|
+
# right move, undone, reported as success. Which is why the fallback in
|
|
1165
|
+
# switch_client hangs on the rc and not on the verdict — see the comment
|
|
1166
|
+
# there; a verdict-gated fallback keeps that drag alive for every rc-0
|
|
1167
|
+
# switch the gate merely cannot vouch for.
|
|
1168
|
+
|
|
1169
|
+
def _attached_clients(self, session: str) -> int | None:
|
|
1170
|
+
"""Clients attached to ``session``, or None when psmux cannot say.
|
|
1171
|
+
|
|
1172
|
+
Self-detecting on purpose, in two directions. An unsupported format
|
|
1173
|
+
field cannot answer a plausible integer, so a build that does not carry
|
|
1174
|
+
``#{session_attached}`` degrades to None instead of a wrong count. And
|
|
1175
|
+
the read reports the session it answered FOR: ``-t`` resolves through
|
|
1176
|
+
the named port AND key files, so when that pair addresses some OTHER
|
|
1177
|
+
live server the answer is that server's — rc 0, plausible count, wrong
|
|
1178
|
+
session (measured on 3.3.8: port and key copied from a live session
|
|
1179
|
+
answered ``0|alive`` for ``-t forged``, naming itself). Both files are
|
|
1180
|
+
required — a key mismatch is rejected before the command runs — so this
|
|
1181
|
+
is a duplicated registry entry, not a merely recycled port. Comparing
|
|
1182
|
+
the answered name against the requested one is what refuses it. The seam's
|
|
1183
|
+
``={session}`` token cannot: psmux strips the ``=`` before it routes
|
|
1184
|
+
anything (``parse_target`` → ``strip_exact_match_prefix``), so both
|
|
1185
|
+
spellings were byte-identical in every probed state. What survives the
|
|
1186
|
+
compare is a genuine same-NAME session on a foreign server, which no
|
|
1187
|
+
name can separate — see the module docstring and #531.
|
|
1188
|
+
|
|
1189
|
+
The count comes FIRST in the format so the split is unambiguous: it is
|
|
1190
|
+
all digits and so cannot contain the separator, which leaves the name
|
|
1191
|
+
as the whole remainder even when the name contains one.
|
|
1192
|
+
"""
|
|
1193
|
+
# The #221 rule the sibling call sites already apply (see
|
|
1194
|
+
# current_return_target): `a:b` parses as session `a`, window `b`, so a
|
|
1195
|
+
# `:`-bearing name addresses something else entirely. Refuse before
|
|
1196
|
+
# spawning rather than trusting what comes back.
|
|
1197
|
+
if not session or ":" in session:
|
|
1198
|
+
return None
|
|
1199
|
+
try:
|
|
1200
|
+
proc = self._run(
|
|
1201
|
+
["display-message", "-p", "-t", session, "#{session_attached}|#{session_name}"],
|
|
1202
|
+
check=False,
|
|
1203
|
+
)
|
|
1204
|
+
except (subprocess.SubprocessError, OSError):
|
|
1205
|
+
return None
|
|
1206
|
+
if proc.returncode != 0:
|
|
1207
|
+
return None
|
|
1208
|
+
# `session` reaches here from `current_session`, which is
|
|
1209
|
+
# `_display_message` and therefore ALREADY `.strip()`ped, so the name we
|
|
1210
|
+
# ask with is the normalized one. That bounds what this compare can and
|
|
1211
|
+
# cannot do, in both directions:
|
|
1212
|
+
#
|
|
1213
|
+
# - A session whose REAL name carries surrounding whitespace is
|
|
1214
|
+
# unreachable through the normalized name anyway — the registry is
|
|
1215
|
+
# one file per name, so `-t ctl` finds no `ctl.port` for a session
|
|
1216
|
+
# named `" ctl"` and fails closed at rc 1. There is no own-count case
|
|
1217
|
+
# to protect here, which is why the answer is stripped the same way
|
|
1218
|
+
# rather than preserved.
|
|
1219
|
+
# - The converse is the hazard: with a real `"ctl"` also present,
|
|
1220
|
+
# `-t ctl` reaches THAT session and its count passes the compare.
|
|
1221
|
+
# Measured on 3.3.8 — `" ctl"` and `"ctl"` coexist as ` ctl.port` and
|
|
1222
|
+
# `ctl.port`, and the read answers `0|ctl`. A name cannot separate
|
|
1223
|
+
# names the seam has already merged; that is the #531 family, the
|
|
1224
|
+
# same ceiling the same-name foreign server sits under.
|
|
1225
|
+
#
|
|
1226
|
+
# Control characters need no handling: psmux escapes them in format
|
|
1227
|
+
# output, so a session whose name ends in a real carriage return answers
|
|
1228
|
+
# with the literal characters `\` and `r` and fails the compare like any
|
|
1229
|
+
# other mismatch. Measured on 3.3.8 through the hardest route available —
|
|
1230
|
+
# duplicate a live session's port and key under a second name, then
|
|
1231
|
+
# rename the original to a CR-bearing name so the surviving alias reaches
|
|
1232
|
+
# a server whose in-memory name differs only by the control character.
|
|
1233
|
+
count, _, answered = proc.stdout.strip().partition("|")
|
|
1234
|
+
if answered != session or not count.isdigit():
|
|
1235
|
+
return None
|
|
1236
|
+
return int(count)
|
|
1237
|
+
|
|
1238
|
+
def _client_left(self, verb: list[str]) -> bool | None:
|
|
1239
|
+
"""Run a client verb and answer whether a client left this session —
|
|
1240
|
+
None both when that cannot be established and when it can but the
|
|
1241
|
+
session had nobody on it to begin with. See
|
|
1242
|
+
TerminalMultiplexer.switch_client for why those share an answer: the
|
|
1243
|
+
seam's False is the joint claim that a client is still HERE, which an
|
|
1244
|
+
empty session refutes rather than supports."""
|
|
1245
|
+
session = self.current_session()
|
|
1246
|
+
if not session:
|
|
1247
|
+
# Not inside a pane (or the probe failed): there is no "this
|
|
1248
|
+
# session" to measure against, and no client of ours to move. No
|
|
1249
|
+
# verb is dispatched, so nothing moved and whoever was here still
|
|
1250
|
+
# is — the joint claim, hence a real False.
|
|
1251
|
+
return False
|
|
1252
|
+
before = self._attached_clients(session)
|
|
1253
|
+
try:
|
|
1254
|
+
self._run(verb, check=False)
|
|
1255
|
+
except subprocess.TimeoutExpired:
|
|
1256
|
+
# The verb may have landed after our wait ran out, and an `after`
|
|
1257
|
+
# read now measures a session the client may still be leaving. No
|
|
1258
|
+
# drop can be established in either direction.
|
|
1259
|
+
return None
|
|
1260
|
+
except (subprocess.SubprocessError, OSError):
|
|
1261
|
+
# A spawn-level fault is proof the verb never ran — but "nothing
|
|
1262
|
+
# moved" is only the first half of the seam's False, and the count
|
|
1263
|
+
# read before the verb still governs the second. An empty or
|
|
1264
|
+
# unreadable session leaves it unvouched here exactly as it does
|
|
1265
|
+
# past the verb, which is what this function's own docstring
|
|
1266
|
+
# promises; probing again now would only measure a broken transport.
|
|
1267
|
+
if before is None or before == 0:
|
|
1268
|
+
return None
|
|
1269
|
+
return False
|
|
1270
|
+
after = self._attached_clients(session)
|
|
1271
|
+
if before is None or after is None:
|
|
1272
|
+
return None
|
|
1273
|
+
if before == 0:
|
|
1274
|
+
# Nothing was attached, so nothing left — but there is nobody here
|
|
1275
|
+
# to keep prompting at either, which is not what False claims.
|
|
1276
|
+
return None
|
|
1277
|
+
return after < before
|
|
1278
|
+
|
|
1279
|
+
def detach_client(self) -> bool:
|
|
1280
|
+
# This leg stays a bool: return_attached_client already routes a failed
|
|
1281
|
+
# detach to UNREACHABLE, so the state a None would add is one the
|
|
1282
|
+
# caller has no separate answer for.
|
|
1283
|
+
return self._client_left(["detach-client"]) is True
|
|
1284
|
+
|
|
1285
|
+
def switch_client(self, target: str, last_fallback: bool = False) -> bool | None:
|
|
1286
|
+
# Two questions, deliberately separate: did the verb SUCCEED (rc), and
|
|
1287
|
+
# was there a client here to succeed on (the gate count, read BEFORE the
|
|
1288
|
+
# verb — a successful move can take the client off this session, so a
|
|
1289
|
+
# read taken after would answer 0 for the very case it has to admit).
|
|
1290
|
+
#
|
|
1291
|
+
# The fallback hangs on the rc alone, never on the verdict. `-l` has no
|
|
1292
|
+
# target form: it relocates whichever client the server last had, so
|
|
1293
|
+
# firing it after a `-t` that already succeeded is the #659 drag itself —
|
|
1294
|
+
# the correct move undone, and the delta the drag produces then read as
|
|
1295
|
+
# success. An rc-0 switch we merely cannot VOUCH for (count unreadable,
|
|
1296
|
+
# or zero clients here) is still a switch that happened or a no-op that
|
|
1297
|
+
# moved nobody; either way there is nothing to fall back to. This is the
|
|
1298
|
+
# `$LASTEXITCODE -ne 0` rule the pwsh trailer (_parked_trailer) has
|
|
1299
|
+
# always used, and the same shape as the tmux leaf's switch_client.
|
|
1300
|
+
#
|
|
1301
|
+
# What the gate cannot VOUCH for answers None rather than False. The
|
|
1302
|
+
# seam's False is the joint claim "no switch happened AND the client is
|
|
1303
|
+
# still here", and every unvouched case below is one where the client
|
|
1304
|
+
# may already be gone; collapsing them into False sends the return path
|
|
1305
|
+
# back to prompting a window nobody is viewing, which a --repeat sweep
|
|
1306
|
+
# then blocks on forever (the parked trailer's retry is no rescue — it
|
|
1307
|
+
# sits behind that same blocking read).
|
|
1308
|
+
session = self.current_session()
|
|
1309
|
+
if not session:
|
|
1310
|
+
# Not inside a pane (or the probe failed): there is no "this
|
|
1311
|
+
# session" to measure against, and no client of ours to move. No
|
|
1312
|
+
# verb is dispatched, so nothing moved — and when it is the probe
|
|
1313
|
+
# that failed rather than the pane that is absent, a wedged server
|
|
1314
|
+
# is a frozen terminal someone may still be sitting at. The second
|
|
1315
|
+
# half of the claim is a policy call here, not a measurement, and
|
|
1316
|
+
# False is the half that keeps talking to them.
|
|
1317
|
+
return False
|
|
1318
|
+
before = self._attached_clients(session)
|
|
1319
|
+
try:
|
|
1320
|
+
sent = self._run(["switch-client", "-t", target], check=False).returncode == 0
|
|
1321
|
+
except subprocess.TimeoutExpired:
|
|
1322
|
+
# A timeout is not a failure — it is the absence of an answer. The
|
|
1323
|
+
# server may well have moved the client before our wait ran out, so
|
|
1324
|
+
# this is the one exit that must neither claim the move nor fall
|
|
1325
|
+
# back: `-l` on a client that already went where it was asked is the
|
|
1326
|
+
# #659 drag, and the drag manufactures the delta that would report it
|
|
1327
|
+
# as a success. None leaves the caller its return option (cleared
|
|
1328
|
+
# only on a True) AND stops it prompting a window the client may
|
|
1329
|
+
# have left.
|
|
1330
|
+
return None
|
|
1331
|
+
except (subprocess.SubprocessError, OSError):
|
|
1332
|
+
# Spawn-level faults, by contrast, are proof the verb never ran:
|
|
1333
|
+
# nothing moved, so the fallback is as available as after a nonzero rc.
|
|
1334
|
+
sent = False
|
|
1335
|
+
if sent:
|
|
1336
|
+
# "Cannot say" and "nobody here" are different facts — an old build
|
|
1337
|
+
# with no #{session_attached} versus a session the client has
|
|
1338
|
+
# already left — and both refuse the True. They share a verdict
|
|
1339
|
+
# only because neither can vouch that a human is still watching.
|
|
1340
|
+
if before is None or before == 0:
|
|
1341
|
+
return None
|
|
1342
|
+
return True
|
|
1343
|
+
if last_fallback:
|
|
1344
|
+
return self._client_left(["switch-client", "-l"])
|
|
1345
|
+
# The refusal carries the joint claim only if a client was measurably
|
|
1346
|
+
# here to refuse on. Same gate as the rc-0 arm, for the same reason: an
|
|
1347
|
+
# unreadable count and an empty session each leave the "still here" half
|
|
1348
|
+
# unvouched, and which way the rc went does not touch that half. tmux
|
|
1349
|
+
# reaches this by probing AFTER a failed verb; the pre-verb read is what
|
|
1350
|
+
# lets this leaf answer it on the spawn-fault exit too, where a probe
|
|
1351
|
+
# taken now would be measuring an already-broken transport.
|
|
1352
|
+
if before is None or before == 0:
|
|
1353
|
+
return None
|
|
1354
|
+
return False
|
|
1355
|
+
|
|
1356
|
+
def pipe_pane(self, window_id: str, log_file: Path) -> None:
|
|
1357
|
+
# The base's POSIX `cat >>` sink assumes a POSIX host shell, so the sink
|
|
1358
|
+
# is pwsh source shipped through the same `-EncodedCommand` transport as
|
|
1359
|
+
# every other window command (psmux 3.3.8 passes dash-flag tokens and
|
|
1360
|
+
# quoting through pipe-pane intact — psmux/psmux#482, psmux/psmux#563).
|
|
1361
|
+
# Space-joining the wrapped argv needs no quoting of its own: base64 is
|
|
1362
|
+
# `[A-Za-z0-9+/=]`, and the log path rides inside the encoded source via
|
|
1363
|
+
# _pwsh_quote, so a spaced, `$`-bearing or backticked path is carried
|
|
1364
|
+
# verbatim rather than re-parsed. The sink is byte-exact like `cat >>`
|
|
1365
|
+
# (raw stream copy: no console decode of the pane bytes, no re-encode, no
|
|
1366
|
+
# CRLF normalization) and flushes per chunk: the run log is live-tailed
|
|
1367
|
+
# for activity detection, and a buffered copy never surfaces bytes — pipe
|
|
1368
|
+
# EOF is unreliable on psmux. Known ceiling: a spawn race that exits 0
|
|
1369
|
+
# still yields a silent empty log — the warning below covers surfaced
|
|
1370
|
+
# failures only.
|
|
1371
|
+
sink = (
|
|
1372
|
+
"$in = [System.Console]::OpenStandardInput()\n"
|
|
1373
|
+
f"$out = [System.IO.File]::Open({_pwsh_quote(str(log_file))}, "
|
|
1374
|
+
"'Append', 'Write', 'Read')\n"
|
|
1375
|
+
"$buf = New-Object byte[] 4096\n"
|
|
1376
|
+
"while (($n = $in.Read($buf, 0, $buf.Length)) -gt 0) "
|
|
1377
|
+
"{ $out.Write($buf, 0, $n); $out.Flush() }\n"
|
|
1378
|
+
"$out.Dispose()\n"
|
|
1379
|
+
)
|
|
1380
|
+
try:
|
|
1381
|
+
self._tmux("pipe-pane", "-t", window_id, "-o", " ".join(self._shell_wrap(sink)))
|
|
1382
|
+
except (TmuxError, UnicodeEncodeError) as exc:
|
|
1383
|
+
# Best-effort, as the base: a window that died on launch (or psmux's
|
|
1384
|
+
# first-pipe-after-new-window spawn race, noted in psmux/psmux#482)
|
|
1385
|
+
# is not a setup failure — but say so, or an empty run log is
|
|
1386
|
+
# unexplainable. UnicodeEncodeError joins it because the sink source
|
|
1387
|
+
# (log path included) rides a UTF-16LE encode that _tmux never sees;
|
|
1388
|
+
# a timeout / missing binary already arrives as TmuxError from there.
|
|
1389
|
+
print(
|
|
1390
|
+
f"warning: pipe-pane log capture failed for {window_id}: {exc}",
|
|
1391
|
+
file=sys.stderr,
|
|
1392
|
+
)
|
|
1393
|
+
|
|
1394
|
+
# Releases up to this version are refused. 3.3.6 and older force-kill a
|
|
1395
|
+
# recycled PID during pane teardown and let orphaned servers accumulate —
|
|
1396
|
+
# engine-fatal on its own. 3.3.7 is excluded for a second reason: 3.3.8 is
|
|
1397
|
+
# the build this backend is written against, and several verbs here now
|
|
1398
|
+
# assume its fixes rather than routing around the defects (a direct
|
|
1399
|
+
# `pipe-pane -o` flag transport, a `select-window` id target, the `=name`
|
|
1400
|
+
# kill-session form, and the control-line shapes `_transportable` admits).
|
|
1401
|
+
# On 3.3.7 those would fail silently, so the floor forbids it outright.
|
|
1402
|
+
_LAST_UNSUPPORTED = (3, 3, 7)
|
|
1403
|
+
# Class-level default; instances shadow it on first probe. Never assign on
|
|
1404
|
+
# the class outside tests — that would poison every future instance.
|
|
1405
|
+
_version_ok: bool | None = None
|
|
1406
|
+
|
|
1407
|
+
def available(self) -> bool:
|
|
1408
|
+
# Every window launch needs pwsh alongside the psmux binary itself.
|
|
1409
|
+
# The version gate fails closed: psmux prints `tmux X.Y.Z` (the tmux
|
|
1410
|
+
# prefix is kept deliberately for tmux-version parsers), and an old or
|
|
1411
|
+
# unidentifiable install reads as unusable. A forced backend name (env
|
|
1412
|
+
# var or policy) still bypasses this probe, with a warning at the
|
|
1413
|
+
# launch gates (see multiplexer.mux_usable). The gate verdict is cached
|
|
1414
|
+
# on the instance so repeated availability polls don't each spawn a
|
|
1415
|
+
# version query; the lru-cached selected instance re-probes a swapped
|
|
1416
|
+
# install only on restart (detect_multiplexers' fresh instances
|
|
1417
|
+
# re-probe every call).
|
|
1418
|
+
if not all(shutil.which(exe) for exe in (self._BINARY, "pwsh")):
|
|
1419
|
+
return False
|
|
1420
|
+
if self._version_ok is None:
|
|
1421
|
+
# A missing patch segment reads as 0 — psmux hardwires three-part
|
|
1422
|
+
# Cargo semver today, but real tmux versions are two-part and the
|
|
1423
|
+
# compat prefix invites upstream to mirror that format someday.
|
|
1424
|
+
reported = re.match(r"tmux (\d+)\.(\d+)(?:\.(\d+))?", self.version() or "")
|
|
1425
|
+
self._version_ok = bool(reported) and (
|
|
1426
|
+
tuple(int(part or 0) for part in reported.groups()) > self._LAST_UNSUPPORTED
|
|
1427
|
+
)
|
|
1428
|
+
return self._version_ok
|