froid-loop 0.11.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
@@ -0,0 +1,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