From 5c3f90e910f899ae4fe1a4ff8a4afe1b35aa642d Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Wed, 15 Jul 2026 20:03:28 +0800 Subject: [PATCH] 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. --- README.md | 6 +++ myagents/launcher.py | 70 ++++++++++++++++++++++++++++++++--- tests/test_entrypoints.py | 77 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ad8e5ef..727d860 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,14 @@ mycodex -r mycodex resume myhermes myhermes --resume + +# tmux 防断线(SSH 远程场景) +myclaude -t # 在 tmux 会话中启动,掉线/合盖不丢会话 +myclaude -t -r # 与透传参数任意组合 ``` +`--tmux` / `-t` 会把 agent 包进一个**按后端 + 目录命名**的 tmux 会话(如 `myclaude-myagents-a1b2c3`):重复执行同一命令即重新接入(`tmux new-session -A` 语义),`C-b d` 脱离。已在 tmux 内时自动退化为直接运行;未安装 tmux 会给出明确提示。 + 各后端原生参数会原样透传。例如 Kimi npm 版支持 `--session`、`-c`(continue)、`-y`(yolo)等,Codex 支持 `resume`、`continue`、`exec` 等子命令;`mycodex` 还会将 `-r` / `--resume` 映射为 `codex resume`,与 `myclaude` 体验保持一致。Hermes 默认带 `--yolo` 启动,支持 `--resume ` / `--continue` 透传;其 sessions 存在全局 SQLite(`~/.hermes/state.db`),不区分工作目录,`-l` 列出最近 20 条。 ## 配置 diff --git a/myagents/launcher.py b/myagents/launcher.py index bc472ad..e908e8e 100644 --- a/myagents/launcher.py +++ b/myagents/launcher.py @@ -4,6 +4,7 @@ import hashlib import json import os import re +import shlex import shutil import sqlite3 import subprocess @@ -125,7 +126,50 @@ def _translate_hermes_extra(extra: list[str]) -> list[str]: return extra -def _launch(backend: str, chat_cwd: Path, extra: list[str]) -> None: +def _tmux_session_name(backend: str, chat_cwd: Path) -> str: + """Deterministic per-backend, per-directory tmux session name. + + Re-running ``myclaude --tmux`` in the same directory reattaches to the + same session; the digest keeps same-named directories from colliding. + """ + slug = re.sub(r"[^A-Za-z0-9_-]", "-", chat_cwd.name).strip("-") or "root" + digest = hashlib.md5(str(chat_cwd).encode("utf-8")).hexdigest()[:6] # noqa: S324 + return f"my{backend}-{slug}-{digest}" + + +def _exec_tmux(backend: str, chat_cwd: Path, cmd: list[str]) -> None: + """Run cmd inside an attachable tmux session. Never returns.""" + if os.environ.get("TMUX"): + stderr_console.print( + "[yellow]Already inside tmux; running without a nested session.[/yellow]" + ) + proc = subprocess.run(cmd, cwd=str(chat_cwd), check=False) + raise SystemExit(proc.returncode) + + tmux = shutil.which("tmux") + if not tmux: + stderr_console.print( + "[red]tmux not found in PATH.[/red] Install it (e.g. " + "[cyan]sudo apt install tmux[/cyan] / [cyan]brew install tmux[/cyan]) " + "or drop [cyan]--tmux[/cyan]." + ) + raise SystemExit(127) + + name = _tmux_session_name(backend, chat_cwd) + stderr_console.print( + f"[dim]tmux session[/dim] [cyan]{name}[/cyan] " + "[dim](detach: C-b d, reattach: same command)[/dim]" + ) + proc = subprocess.run( + [tmux, "new-session", "-A", "-s", name, "-c", str(chat_cwd), shlex.join(cmd)], + check=False, + ) + raise SystemExit(proc.returncode) + + +def _launch( + backend: str, chat_cwd: Path, extra: list[str], use_tmux: bool = False +) -> None: """Run backend CLI in chat_cwd, forwarding extra args. Never returns.""" config = _BACKENDS[backend] binary = os.environ.get(config["env_bin"]) or shutil.which(config["binary"]) @@ -139,6 +183,8 @@ def _launch(backend: str, chat_cwd: Path, extra: list[str]) -> None: extra = _translate_hermes_extra(extra) cmd = [binary, *config["default_args"], *extra] + if use_tmux: + _exec_tmux(backend, chat_cwd, cmd) proc = subprocess.run(cmd, cwd=str(chat_cwd), check=False) raise SystemExit(proc.returncode) @@ -449,8 +495,20 @@ def build_cli(backend: str, prog_name: str | None = None) -> click.Group: default=False, help=f"List resumable {backend} sessions for the working directory and exit.", ) + @click.option( + "--tmux", + "-t", + is_flag=True, + default=False, + help=( + "Run inside tmux: reattaches to a per-directory session that " + "survives SSH drops (detach with C-b d)." + ), + ) @click.pass_context - def cli(ctx: click.Context, cwd: str | None, list_sessions: bool) -> None: + def cli( + ctx: click.Context, cwd: str | None, list_sessions: bool, tmux: bool + ) -> None: """Launcher entrypoint; full help is set on the group below.""" if ctx.invoked_subcommand not in (None, "__run__"): return @@ -462,7 +520,7 @@ def build_cli(backend: str, prog_name: str | None = None) -> click.Group: raise SystemExit(0) if ctx.invoked_subcommand is None: - _launch(backend, chat_cwd, []) + _launch(backend, chat_cwd, [], use_tmux=tmux) @cli.command(name="__run__", hidden=True) @click.pass_context @@ -472,11 +530,13 @@ def build_cli(backend: str, prog_name: str | None = None) -> click.Group: assert parent is not None chat_cwd = _resolve_chat_cwd(parent.params.get("cwd")) extra = list(ctx.meta.get("passthrough", [])) - _launch(backend, chat_cwd, extra) + _launch(backend, chat_cwd, extra, use_tmux=bool(parent.params.get("tmux"))) cli.help = ( f"Launch {backend_title} in workspace/.\n\n" - f"Unknown arguments ({_resume_syntax(backend)}, --continue, …) pass through to {backend}." + f"Unknown arguments ({_resume_syntax(backend)}, --continue, …) pass through to {backend}.\n\n" + "With --tmux/-t the agent runs in a per-directory tmux session that " + "survives SSH disconnects; re-run the same command to reattach." ) try: pkg_version = version("myagents") diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index a197fe5..179cd74 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -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