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