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:
@@ -101,8 +101,14 @@ mycodex -r <session-id>
|
|||||||
mycodex resume <session-id>
|
mycodex resume <session-id>
|
||||||
myhermes
|
myhermes
|
||||||
myhermes --resume <session-id>
|
myhermes --resume <session-id>
|
||||||
|
|
||||||
|
# tmux 防断线(SSH 远程场景)
|
||||||
|
myclaude -t # 在 tmux 会话中启动,掉线/合盖不丢会话
|
||||||
|
myclaude -t -r <session-id> # 与透传参数任意组合
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--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 <id>` / `--continue` 透传;其 sessions 存在全局 SQLite(`~/.hermes/state.db`),不区分工作目录,`-l` 列出最近 20 条。
|
各后端原生参数会原样透传。例如 Kimi npm 版支持 `--session`、`-c`(continue)、`-y`(yolo)等,Codex 支持 `resume`、`continue`、`exec` 等子命令;`mycodex` 还会将 `-r` / `--resume` 映射为 `codex resume`,与 `myclaude` 体验保持一致。Hermes 默认带 `--yolo` 启动,支持 `--resume <id>` / `--continue` 透传;其 sessions 存在全局 SQLite(`~/.hermes/state.db`),不区分工作目录,`-l` 列出最近 20 条。
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|||||||
+65
-5
@@ -4,6 +4,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -125,7 +126,50 @@ def _translate_hermes_extra(extra: list[str]) -> list[str]:
|
|||||||
return extra
|
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."""
|
"""Run backend CLI in chat_cwd, forwarding extra args. Never returns."""
|
||||||
config = _BACKENDS[backend]
|
config = _BACKENDS[backend]
|
||||||
binary = os.environ.get(config["env_bin"]) or shutil.which(config["binary"])
|
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)
|
extra = _translate_hermes_extra(extra)
|
||||||
|
|
||||||
cmd = [binary, *config["default_args"], *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)
|
proc = subprocess.run(cmd, cwd=str(chat_cwd), check=False)
|
||||||
raise SystemExit(proc.returncode)
|
raise SystemExit(proc.returncode)
|
||||||
|
|
||||||
@@ -449,8 +495,20 @@ def build_cli(backend: str, prog_name: str | None = None) -> click.Group:
|
|||||||
default=False,
|
default=False,
|
||||||
help=f"List resumable {backend} sessions for the working directory and exit.",
|
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
|
@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."""
|
"""Launcher entrypoint; full help is set on the group below."""
|
||||||
if ctx.invoked_subcommand not in (None, "__run__"):
|
if ctx.invoked_subcommand not in (None, "__run__"):
|
||||||
return
|
return
|
||||||
@@ -462,7 +520,7 @@ def build_cli(backend: str, prog_name: str | None = None) -> click.Group:
|
|||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
if ctx.invoked_subcommand is None:
|
if ctx.invoked_subcommand is None:
|
||||||
_launch(backend, chat_cwd, [])
|
_launch(backend, chat_cwd, [], use_tmux=tmux)
|
||||||
|
|
||||||
@cli.command(name="__run__", hidden=True)
|
@cli.command(name="__run__", hidden=True)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@@ -472,11 +530,13 @@ def build_cli(backend: str, prog_name: str | None = None) -> click.Group:
|
|||||||
assert parent is not None
|
assert parent is not None
|
||||||
chat_cwd = _resolve_chat_cwd(parent.params.get("cwd"))
|
chat_cwd = _resolve_chat_cwd(parent.params.get("cwd"))
|
||||||
extra = list(ctx.meta.get("passthrough", []))
|
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 = (
|
cli.help = (
|
||||||
f"Launch {backend_title} in workspace/.\n\n"
|
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:
|
try:
|
||||||
pkg_version = version("myagents")
|
pkg_version = version("myagents")
|
||||||
|
|||||||
@@ -143,3 +143,80 @@ class TestMyhermesEntrypoint:
|
|||||||
"abc123",
|
"abc123",
|
||||||
"--no-restore-cwd",
|
"--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