feat: add --tmux/-t option to run agents in attachable tmux sessions
Wraps the backend in 'tmux new-session -A' with a per-backend, per-directory session name, so agent sessions survive SSH drops and re-running the same command reattaches. Falls back to a direct run when already inside tmux, and errors clearly when tmux is not installed.
This commit is contained in:
@@ -143,3 +143,80 @@ class TestMyhermesEntrypoint:
|
||||
"abc123",
|
||||
"--no-restore-cwd",
|
||||
]
|
||||
|
||||
|
||||
class TestTmuxOption:
|
||||
"""``--tmux`` / ``-t`` wraps the backend in an attachable tmux session."""
|
||||
|
||||
def _which(self, name: str) -> str:
|
||||
return f"/usr/bin/{name}"
|
||||
|
||||
def test_tmux_wraps_command(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", side_effect=self._which),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
patch.dict("os.environ", {}, clear=False) as env,
|
||||
):
|
||||
env.pop("TMUX", None)
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(claude_cli, ["--cwd", str(tmp_path), "--tmux"])
|
||||
assert result.exit_code == 0
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv[:3] == ["/usr/bin/tmux", "new-session", "-A"]
|
||||
assert "-c" in argv and str(tmp_path) in argv
|
||||
assert argv[-1] == "/usr/bin/claude --dangerously-skip-permissions"
|
||||
|
||||
def test_tmux_session_name_deterministic(self, tmp_path: Path) -> None:
|
||||
from myagents.launcher import _tmux_session_name
|
||||
|
||||
first = _tmux_session_name("claude", tmp_path)
|
||||
second = _tmux_session_name("claude", tmp_path)
|
||||
other = _tmux_session_name("kimi", tmp_path)
|
||||
assert first == second
|
||||
assert first != other
|
||||
assert first.startswith("myclaude-")
|
||||
|
||||
def test_tmux_with_passthrough(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", side_effect=self._which),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
patch.dict("os.environ", {}, clear=False) as env,
|
||||
):
|
||||
env.pop("TMUX", None)
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(
|
||||
claude_cli, ["--cwd", str(tmp_path), "-t", "-r", "sess-1"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv[0] == "/usr/bin/tmux"
|
||||
assert argv[-1].endswith("-r sess-1")
|
||||
|
||||
def test_tmux_skipped_when_already_inside(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", side_effect=self._which),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
patch.dict("os.environ", {"TMUX": "/tmp/tmux-501/default,1,0"}),
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(claude_cli, ["--cwd", str(tmp_path), "-t"])
|
||||
assert result.exit_code == 0
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv[0] == "/usr/bin/claude"
|
||||
|
||||
def test_tmux_missing_binary(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
def _which(name: str) -> str | None:
|
||||
return None if name == "tmux" else f"/usr/bin/{name}"
|
||||
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", side_effect=_which),
|
||||
patch.dict("os.environ", {}, clear=False) as env,
|
||||
):
|
||||
env.pop("TMUX", None)
|
||||
result = runner.invoke(claude_cli, ["--cwd", str(tmp_path), "-t"])
|
||||
assert result.exit_code == 127
|
||||
|
||||
Reference in New Issue
Block a user