tmux-amq/src/tamq/tmux.py
tegwick e995cab1b8
Some checks failed
tamq-ci / test (push) Failing after 5s
feat: add shell-native message routing
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
2026-08-24 20:42:35 +02:00

263 lines
11 KiB
Python

from __future__ import annotations
import os
import re
import shlex
import shutil
from uuid import uuid4
import subprocess
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from .config import state_dir
from .registry import repository_paths, validate_targets
class TmuxError(RuntimeError):
pass
@dataclass
class Endpoint:
session: str
pid: int
repos: list[str]
instance_id: str = ""
created_session: bool = False
created_windows: tuple[str, ...] = ()
@property
def endpoint_id(self) -> str:
return f"tmux-amq-{self.pid}"
@property
def instance_key(self) -> str:
return self.instance_id or self.endpoint_id
@dataclass(frozen=True)
class LaunchPlan:
repos: tuple[str, ...]
paths: dict[str, str]
command: tuple[str, ...]
class TmuxManager:
def __init__(
self,
session: str = "tamq",
*,
tmux_command: Sequence[str] | None = None,
tamq_command: Sequence[str] | None = None,
command_dir: Path | None = None,
):
self.session = session
socket_name = os.environ.get("TAMQ_TMUX_SOCKET")
self.tmux_command = tuple(tmux_command or (["tmux", "-L", socket_name] if socket_name else ["tmux"]))
if tamq_command is None:
self.tamq_command = (shutil.which("tamq") or "tamq",)
else:
self.tamq_command = tuple(tamq_command)
configured_command_dir = os.environ.get("TAMQ_COMMAND_DIR")
if command_dir is not None:
self.command_dir = command_dir
elif configured_command_dir:
self.command_dir = Path(configured_command_dir)
elif (
len(self.tamq_command) == 1
and Path(self.tamq_command[0]).is_absolute()
and os.access(Path(self.tamq_command[0]).parent, os.W_OK | os.X_OK)
):
self.command_dir = Path(self.tamq_command[0]).parent
else:
self.command_dir = state_dir() / "commands" / session
def _install_address_commands(self, repos: Sequence[str]) -> None:
"""Install tamq-owned commands without modifying a user's shell files."""
self.command_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
if not os.access(self.command_dir, os.W_OK | os.X_OK):
raise TmuxError(f"address command directory is not writable: {self.command_dir}")
for repo in repos:
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is None:
raise TmuxError(
f"repository name cannot be exposed as a shell command: {repo!r}"
)
target = f"@{repo}:"
script = (
"#!/bin/sh\n"
"# tamq-address-command v1\n"
f"exec {shell_join(list(self.tamq_command))} send -- {shlex.quote(target)} \"$@\"\n"
)
for name in (f"@{repo}", target):
destination = self.command_dir / name
if destination.exists() or destination.is_symlink():
if destination.is_symlink() or not destination.is_file():
raise TmuxError(f"refusing to replace existing command: {destination}")
try:
existing = destination.read_text(encoding="utf-8")
except OSError as exc:
raise TmuxError(f"cannot inspect existing command: {destination}") from exc
if "# tamq-address-command v1" not in existing:
raise TmuxError(f"refusing to replace existing command: {destination}")
temporary = self.command_dir / f".{name}.{uuid4().hex}.tmp"
temporary.write_text(script, encoding="utf-8")
temporary.chmod(0o700)
temporary.replace(destination)
def _window_environment(self, repo: str) -> tuple[str, ...]:
path = os.environ.get("PATH", "")
command_path = f"{self.command_dir}{os.pathsep}{path}" if path else str(self.command_dir)
return "-e", f"TAMQ_REPO={repo}", "-e", f"PATH={command_path}"
def _run(self, *args: str, check: bool = True) -> str:
result = subprocess.run([*self.tmux_command, *args], text=True, capture_output=True, check=False)
if check and result.returncode:
raise TmuxError(result.stderr.strip() or "tmux command failed")
return result.stdout.strip()
def _existing_session_identity(self) -> tuple[int, str] | None:
result = subprocess.run(
[*self.tmux_command, "has-session", "-t", self.session],
capture_output=True,
)
if result.returncode != 0:
return None
pid_text = self._run("display-message", "-p", "-t", self.session, "#{pid}")
try:
pid = int(pid_text)
except ValueError as exc:
raise TmuxError(f"unable to determine tmux PID: {pid_text!r}") from exc
instance_id = self._run(
"show-options", "-v", "-t", self.session, "@tamq_instance_id", check=False,
)
managed = self._run(
"show-options", "-v", "-t", self.session, "@tamq_managed", check=False,
)
if managed != "1" and not instance_id.startswith(f"tmux-amq-{pid}-"):
raise TmuxError(
f"tmux session already exists and is not managed by tamq: {self.session}"
)
return pid, instance_id
def preflight(self, repos: list[str], command: str | None = None) -> LaunchPlan:
if not repos:
raise TmuxError("at least one repository is required")
if not self.tmux_command or shutil.which(self.tmux_command[0]) is None:
raise TmuxError("tmux is required; install it and ensure 'tmux' is on PATH")
if shutil.which("gita") is None:
raise TmuxError("gita is required; install it and ensure 'gita' is on PATH")
command_parts: tuple[str, ...] = ()
if command is not None:
try:
command_parts = tuple(shlex.split(command))
except ValueError as exc:
raise TmuxError(f"invalid initial command: {exc}") from exc
if not command_parts:
raise TmuxError("initial command must not be empty")
if shutil.which(command_parts[0]) is None:
raise TmuxError(f"initial command not found on PATH: {command_parts[0]}")
unique_repos = tuple(dict.fromkeys(repos))
try:
validate_targets(list(unique_repos))
except Exception as exc:
raise TmuxError(str(exc)) from exc
paths = repository_paths()
selected_paths: dict[str, str] = {}
for repo in unique_repos:
try:
repo_path = paths[repo]
except KeyError as exc:
raise TmuxError(f"gita path missing for registered repository: {repo}") from exc
if not Path(repo_path).is_dir():
raise TmuxError(f"gita path is not a directory for registered repository {repo}: {repo_path}")
selected_paths[repo] = repo_path
self._existing_session_identity()
return LaunchPlan(unique_repos, selected_paths, command_parts)
def ensure(self, repos: list[str], command: str | None = None) -> Endpoint:
return self.ensure_plan(self.preflight(repos, command))
def ensure_plan(self, plan: LaunchPlan, *, tap: bool = False) -> Endpoint:
if tap and not plan.command:
raise TmuxError("PTY tap requires an explicit initial command")
created_session = False
created_windows: list[str] = []
try:
identity = self._existing_session_identity()
if identity is None:
first_repo = plan.repos[0]
self._run(
"new-session", "-d", "-s", self.session, "-n", "__tamq_boot",
*self._window_environment(first_repo),
"-c", plan.paths[first_repo],
)
created_session = True
pid_text = self._run("display-message", "-p", "-t", self.session, "#{pid}")
try:
pid = int(pid_text)
except ValueError as exc:
raise TmuxError(f"unable to determine tmux PID: {pid_text!r}") from exc
instance_id = ""
else:
pid, instance_id = identity
endpoint_id = f"tmux-amq-{pid}"
if not instance_id.startswith(f"{endpoint_id}-"):
instance_id = f"{endpoint_id}-{uuid4().hex[:12]}"
self._run("set-option", "-t", self.session, "@tamq_instance_id", instance_id)
self._run("set-option", "-t", self.session, "@tamq_managed", "1")
windows = self._run("list-windows", "-t", self.session, "-F", "#{window_name}").splitlines()
addressable_repos = [repo for repo in windows if repo != "__tamq_boot"]
addressable_repos.extend(repo for repo in plan.repos if repo not in addressable_repos)
self._install_address_commands(addressable_repos)
command_path = self._window_environment(plan.repos[0])[-1].removeprefix("PATH=")
self._run("set-environment", "-t", self.session, "PATH", command_path)
self._run("set-option", "-t", self.session, "@tamq_command_dir", str(self.command_dir))
for index, repo in enumerate(plan.repos):
if repo in windows:
continue
if index == 0 and "__tamq_boot" in windows:
if not created_session:
self._run("respawn-pane", "-k", "-t", f"{self.session}:__tamq_boot", "-c", plan.paths[repo])
self._run("rename-window", "-t", f"{self.session}:__tamq_boot", repo)
windows[windows.index("__tamq_boot")] = repo
else:
self._run(
"new-window", "-d", "-t", self.session, "-n", repo,
*self._window_environment(repo), "-c", plan.paths[repo],
)
windows.append(repo)
created_windows.append(repo)
if plan.command:
command = (
[
*self.tamq_command,
"tap", "--repo", repo, "--endpoint", endpoint_id, "--", *plan.command,
]
if tap
else list(plan.command)
)
self._run("send-keys", "-t", f"{self.session}:{repo}", shell_join(command), "C-m")
self._run("select-window", "-t", f"{self.session}:{plan.repos[0]}")
return Endpoint(
self.session,
pid,
list(plan.repos),
instance_id,
created_session,
tuple(created_windows),
)
except Exception:
self.rollback(Endpoint(self.session, 0, list(plan.repos), created_session=created_session, created_windows=tuple(created_windows)))
raise
def rollback(self, endpoint: Endpoint) -> None:
if endpoint.created_session:
self._run("kill-session", "-t", endpoint.session, check=False)
return
for window in reversed(endpoint.created_windows):
self._run("kill-window", "-t", f"{endpoint.session}:{window}", check=False)
def shell_join(parts: list[str]) -> str:
return " ".join(shlex.quote(part) for part in parts)