Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/adapter-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ To add a backend, build a `TerminalMultiplexer` (`adapters/multiplexer.py`) and
returns the first backend whose `matches` is true, with tmux as the default
fallback. There are two build paths: extend `BaseTmuxBackend` (`adapters/tmux_base.py`)
for a tmux-family backend — overriding only its single spawn primitive `_run()`
plus the few divergent methods (e.g. the parked-window trailer) — or implement
plus the shell-dialect hooks (`_shell_wrap`, `_join_argv`, `_parked_trailer`,
`_source_prefix`, `_window_launch` and the `_EXIT_CAPTURE`/`_ECHO`/`_PARK`
fragments) — or implement
`TerminalMultiplexer` fresh for a host with no tmux-shaped CLI. The non-transport
seams of a full OS port are in
[Porting bmad-loop to a new OS](porting-to-a-new-os.md). The contract groups into:
Expand All @@ -66,9 +68,9 @@ seams of a full OS port are in
(read a user option across all sessions), `set_session_option`.
- **Windows** — `new_window` (run a command in a fresh window), `new_parked_window`
(run a command, then _park_ on a keypress so the exit status stays inspectable,
then return any attached client to its origin — this is where the POSIX `sh -c`
trailer is quarantined; a non-POSIX backend reimplements the same behavior in
its own terms), `list_window_ids`, `list_windows` (selected fields per window),
then return any attached client to its origin — the POSIX `sh -c` recipe is
composed from the base's overridable shell-dialect hooks, so a non-POSIX
backend swaps the dialect fragments, not the method body), `list_window_ids`, `list_windows` (selected fields per window),
`window_alive`, `kill_window`, `select_window`, `set_window_option`,
`unset_window_option`, `show_window_option`, `pipe_pane` (tee a pane to a log),
`send_text`.
Expand Down
11 changes: 7 additions & 4 deletions docs/porting-to-a-new-os.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,13 @@ fallback, so POSIX behavior is unchanged. (The result is cached — see
through one primitive, `_run(argv, *, check=..., env=...)`. A native-Windows
"psmux" that speaks a tmux-like CLI sets the `_ENCODING` class attribute for
output decoding (e.g. `"utf-8"`) and passes a per-call `env=` where needed —
overriding `_run()` itself only to tweak the binary or timeout — plus the few
genuinely divergent methods (e.g. the
parked-window `sh -c` trailer in `new_parked_window`) — **without editing**
`tmux_base.py` or its POSIX leaf `tmux_backend.py` (`TmuxMultiplexer`).
overriding `_run()` itself only to tweak the binary or timeout — plus the
shell-dialect hooks that `new_window` / `new_parked_window` compose from
(`_shell_wrap`, `_join_argv`, `_parked_trailer`, `_source_prefix`,
`_window_launch` and the `_EXIT_CAPTURE`/`_ECHO`/`_PARK` fragments) —
**without editing** `tmux_base.py` or its POSIX leaf `tmux_backend.py`
(`TmuxMultiplexer`). The one method-body override left is `pipe_pane`, whose
POSIX `cat >>` redirection is not behind a hook.
- **Implement `TerminalMultiplexer` fresh** when the host has no tmux-shaped CLI
at all (e.g. a ConPTY-based window manager). You implement the full contract
directly; `tmux_backend.py` is the reference for what each method must produce.
Expand Down
111 changes: 81 additions & 30 deletions src/bmad_loop/adapters/tmux_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@
override only the single spawn primitive :meth:`BaseTmuxBackend._run` (to tweak
the binary or timeout — output decoding is the :attr:`BaseTmuxBackend._ENCODING`
class attribute and a scrubbed per-call ``env`` is a ``_run`` parameter, neither
an override) plus the few divergent methods (e.g. the
parked-window trailer), **without editing** :mod:`.tmux_backend`.
an override) plus the shell-dialect hooks (``_shell_wrap``, ``_join_argv``,
``_parked_trailer``, ``_source_prefix``, ``_window_launch`` and the
``_EXIT_CAPTURE``/``_ECHO``/``_PARK`` fragments), **without editing**
:mod:`.tmux_backend`. For :meth:`~BaseTmuxBackend.new_window` /
:meth:`~BaseTmuxBackend.new_parked_window` the hooks replace method-body
overrides entirely; :meth:`~BaseTmuxBackend.pipe_pane` still hands tmux a POSIX
``cat >>`` redirection, so it remains the one contract method a non-POSIX leaf
overrides directly.

Every method that talks to tmux funnels through :meth:`BaseTmuxBackend._run`, the
one place a subprocess is spawned. See :mod:`.multiplexer` for the contract.
Expand Down Expand Up @@ -158,14 +164,74 @@ def session_options(self, option: str) -> dict[str, str]:
options[name] = value
return options

# ------------------------------------------------- shell dialect seam

# new_window / new_parked_window own the tmux argv construction and the
# parked-window protocol; everything shell-*dialect* about them routes
# through the hooks below so a non-POSIX leaf overrides string fragments,
# never a contract method body. Defaults are POSIX sh, so a non-POSIX leaf
# must override every hook whose default emits sh syntax: the three
# fragments, _join_argv, _parked_trailer, and _shell_wrap.

#: Fragments of the parked recipe. The banner line reads ``$ec`` verbatim
#: and stays dialect-neutral only because every dialect of the family
#: interpolates ``$ec`` inside its double-quoted strings — so an
#: _EXIT_CAPTURE override MUST bind the variable ``ec``.
_EXIT_CAPTURE = "ec=$?"
_ECHO = "echo"
_PARK = "read -r"

def _join_argv(self, argv: list[str]) -> str:
"""Render ``argv`` as one shell command line in this dialect."""
return shlex.join(argv)

def _source_prefix(self) -> str:
"""Dialect prelude prepended to a parked window's shell source.

The recipe adds no separator, so an override must return ``""`` or a
self-terminating statement ending in ``"; "``.
"""
return ""

def _shell_wrap(self, source: str) -> list[str]:
# Explicit `sh -c` (the user's login shell may be fish) — the one place
# a window's shell source is turned into a spawnable argv.
return ["sh", "-c", source]

def _parked_trailer(self, return_opt: str) -> str:
# After the park, switch an attached client back to its origin pane:
# - return_opt == a pane id (%N): switch that client back there
# (`switch-client -l` is a best-effort fallback when it is gone);
# - return_opt == PARKED_RETURN_DETACH: detach the client so a blocking
# `tmux attach` returns and a suspended TUI resumes;
# - unset/empty: nobody attached interactively -> park as-is.
# The tmux verbs are protocol-identical across the family; only the
# surrounding control-flow syntax is dialect-specific.
return (
f"ret=$(tmux show-options -wqv {shlex.quote(return_opt)} 2>/dev/null); "
f'if [ "$ret" = "{PARKED_RETURN_DETACH}" ]; then tmux detach-client 2>/dev/null; '
'elif [ -n "$ret" ]; then '
'tmux switch-client -t "$ret" 2>/dev/null || tmux switch-client -l 2>/dev/null; '
"fi"
)

def _window_launch(self, env: dict[str, str], command: str) -> list[str]:
"""Trailing ``new-window`` args: env injection plus the command itself.

Part of the dialect seam because the env-injection *strategy* is
dialect-coupled: bare ``-e`` flags plus the raw command here, an
in-source prelude for a leaf whose shell wraps the command.
"""
env_args: list[str] = []
for key, value in env.items():
env_args += ["-e", f"{key}={value}"]
return [*env_args, command]

# ------------------------------------------------------------ windows

def new_window(
self, session: str, name: str, cwd: Path, env: dict[str, str], command: str
) -> str:
env_args: list[str] = []
for key, value in env.items():
env_args += ["-e", f"{key}={value}"]
return self._tmux(
"new-window",
"-t",
Expand All @@ -177,33 +243,20 @@ def new_window(
"-P",
"-F",
"#{window_id}",
*env_args,
command,
*self._window_launch(env, command),
)

def new_parked_window(
self, session: str, name: str, cwd: Path, argv: list[str], return_opt: str
) -> str:
# The window runs under explicit `sh -c` (the user's login shell may be
# fish); the trailing `read` keeps the exit status inspectable instead of
# tmux closing the window the moment the process exits. After the read the
# return trailer switches an attached client back to its origin pane:
# - return_opt == a pane id (%N): switch that client back there
# (`switch-client -l` is a best-effort fallback when it is gone);
# - return_opt == PARKED_RETURN_DETACH: detach the client so a blocking
# `tmux attach` returns and a suspended TUI resumes;
# - unset/empty: nobody attached interactively -> park as-is.
return_trailer = (
f"ret=$(tmux show-options -wqv {shlex.quote(return_opt)} 2>/dev/null); "
f'if [ "$ret" = "{PARKED_RETURN_DETACH}" ]; then tmux detach-client 2>/dev/null; '
'elif [ -n "$ret" ]; then '
'tmux switch-client -t "$ret" 2>/dev/null || tmux switch-client -l 2>/dev/null; '
"fi"
)
inner = shlex.join(argv)
shell = (
f'{inner}; ec=$?; echo "[bmad-loop exited $ec — press enter]"; '
f"read -r; {return_trailer}"
# Run argv, then park on a blocking read so the exit status stays
# inspectable instead of tmux closing the window the moment the process
# exits; the trailer (see _parked_trailer) then returns an attached
# client to where it came from.
source = self._source_prefix() + (
f"{self._join_argv(argv)}; {self._EXIT_CAPTURE}; "
f'{self._ECHO} "[bmad-loop exited $ec — press enter]"; '
f"{self._PARK}; {self._parked_trailer(return_opt)}"
)
return self._tmux(
"new-window",
Expand All @@ -217,9 +270,7 @@ def new_parked_window(
name,
"-c",
str(cwd),
"sh",
"-c",
shell,
*self._shell_wrap(source),
)

def list_window_ids(self, session: str) -> list[str]:
Expand Down
150 changes: 149 additions & 1 deletion tests/test_multiplexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,12 +280,14 @@ def _run(self, argv, *, check=True, env=None):


class _RecordRun:
"""Stand-in for subprocess.run that records the kwargs of the one spawn."""
"""Stand-in for subprocess.run that records the argv and kwargs of the one spawn."""

def __init__(self):
self.argv: list = []
self.kwargs: dict = {}

def __call__(self, argv, **kwargs):
self.argv = argv
self.kwargs = kwargs
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")

Expand Down Expand Up @@ -330,3 +332,149 @@ def test_run_custom_env_is_forwarded_without_leaking(monkeypatch):
assert "TMUX" not in rec.kwargs["env"]
# the scrubbed env is confined to the child spawn — this process's env is untouched
assert dict(os.environ) == before


# ------------------------------------------------------- shell-dialect seam
#
# new_window / new_parked_window keep the tmux argv construction and the
# parked-window protocol in the base; only shell-dialect fragments route
# through overridable hooks. Locked two ways: the POSIX output stays
# byte-identical to the pre-seam inline code, and a leaf that overrides only
# the hooks still gets the base's scaffolding without touching a method body.

# the exact sh source the POSIX backend produced before the hooks existed
_PARKED_SH_SOURCE = (
'echo hi; ec=$?; echo "[bmad-loop exited $ec — press enter]"; read -r; '
"ret=$(tmux show-options -wqv %3 2>/dev/null); "
'if [ "$ret" = "detach" ]; then tmux detach-client 2>/dev/null; '
'elif [ -n "$ret" ]; then '
'tmux switch-client -t "$ret" 2>/dev/null || tmux switch-client -l 2>/dev/null; fi'
)


def test_new_parked_window_posix_argv_byte_identical(monkeypatch, tmp_path):
rec = _RecordRun()
monkeypatch.setattr(tmux_base.subprocess, "run", rec)

TmuxMultiplexer().new_parked_window("s", "n", tmp_path, ["echo", "hi"], "%3")

assert rec.argv == [
"tmux",
"new-window",
"-d",
"-P",
"-F",
"#{window_id}",
"-t",
"=s:",
"-n",
"n",
"-c",
str(tmp_path),
"sh",
"-c",
_PARKED_SH_SOURCE,
]


def test_new_window_posix_argv_byte_identical(monkeypatch, tmp_path):
rec = _RecordRun()
monkeypatch.setattr(tmux_base.subprocess, "run", rec)

TmuxMultiplexer().new_window("s", "n", tmp_path, {"A": "1", "B": "2"}, "cmd")

assert rec.argv == [
"tmux",
"new-window",
"-t",
"=s:",
"-n",
"n",
"-c",
str(tmp_path),
"-P",
"-F",
"#{window_id}",
"-e",
"A=1",
"-e",
"B=2",
"cmd",
]


class _FakeDialect(TmuxMultiplexer):
"""A leaf that overrides ONLY the dialect hooks — no contract method bodies."""

_EXIT_CAPTURE = "ec := EXITSTATUS"
_ECHO = "say"
_PARK = "pause"

def _join_argv(self, argv):
return "run " + " ".join(f"<{a}>" for a in argv)

def _source_prefix(self):
return "PRELUDE; "

def _shell_wrap(self, source):
return ["fakesh", "-enc", source]

def _parked_trailer(self, return_opt):
return f"TRAILER({return_opt})"

def _window_launch(self, env, command):
return [f"wrapped:{command}"]


def test_dialect_leaf_parked_window_composes_from_hooks(monkeypatch, tmp_path):
rec = _RecordRun()
monkeypatch.setattr(tmux_base.subprocess, "run", rec)

_FakeDialect().new_parked_window("s", "n", tmp_path, ["echo", "hi"], "%3")

# the tmux scaffolding is the base's, unchanged
assert rec.argv[:12] == [
"tmux",
"new-window",
"-d",
"-P",
"-F",
"#{window_id}",
"-t",
"=s:",
"-n",
"n",
"-c",
str(tmp_path),
]
# the shell source is composed prefix + inner + capture + banner + park + trailer
assert rec.argv[12:] == [
"fakesh",
"-enc",
"PRELUDE; run <echo> <hi>; ec := EXITSTATUS; "
'say "[bmad-loop exited $ec — press enter]"; '
"pause; TRAILER(%3)",
]


def test_dialect_leaf_new_window_routes_launch_through_hook(monkeypatch, tmp_path):
rec = _RecordRun()
monkeypatch.setattr(tmux_base.subprocess, "run", rec)

_FakeDialect().new_window("s", "n", tmp_path, {"A": "1"}, "cmd")

assert rec.argv == [
"tmux",
"new-window",
"-t",
"=s:",
"-n",
"n",
"-c",
str(tmp_path),
"-P",
"-F",
"#{window_id}",
"wrapped:cmd",
]
assert "-e" not in rec.argv # env strategy fully delegated to the hook
Loading