diff --git a/nanobot/cli/entry.py b/nanobot/cli/entry.py index 4a2c6f975..b7ccc6c20 100644 --- a/nanobot/cli/entry.py +++ b/nanobot/cli/entry.py @@ -6,6 +6,8 @@ import os import sys from contextlib import suppress +from nanobot.cli.process_identity import set_cli_process_identity + def _native_tui_candidate(args: list[str]) -> bool: """Return whether ``agent`` can start without the classic agent stack.""" @@ -34,6 +36,7 @@ def _configure_windows_console() -> None: def main() -> None: """Dispatch native TUI startup without importing the complete CLI graph.""" + set_cli_process_identity(sys.argv[1:]) _configure_windows_console() if _native_tui_candidate(sys.argv[1:]): import typer diff --git a/nanobot/cli/process_identity.py b/nanobot/cli/process_identity.py new file mode 100644 index 000000000..f7828a11d --- /dev/null +++ b/nanobot/cli/process_identity.py @@ -0,0 +1,45 @@ +"""Give nanobot processes recognizable operating-system names.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +from typing import Final + +from setproctitle import setproctitle + +_ROLES: Final = {"agent", "gateway", "webui"} + + +def set_cli_process_identity(args: list[str]) -> None: + """Name this CLI process after the nanobot role it is running.""" + if os.name == "nt": + # Windows process managers use the console launcher's executable name, + # which packaging already generates as ``nanobot.exe``. + return + role = args[0] if args and args[0] in _ROLES else None + setproctitle(f"nanobot-{role}" if role else "nanobot") + + +def named_executable(executable: str, *, name: str, directory: Path) -> str: + """Return a stable POSIX symlink whose basename identifies a child process.""" + if os.name == "nt": + return executable + try: + target = Path(executable).resolve(strict=True) + digest = hashlib.sha256(os.fsencode(target)).hexdigest()[:12] + link_dir = directory / digest + link = link_dir / name + link_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + if link.is_symlink() and link.resolve(strict=False) == target: + return str(link) + if link.exists(): + return executable + pending = link.with_name(f".{name}.{os.getpid()}") + pending.unlink(missing_ok=True) + pending.symlink_to(target) + os.replace(pending, link) + except OSError: + return executable + return str(link) diff --git a/nanobot/cli/tui_launcher.py b/nanobot/cli/tui_launcher.py index 7ce3f92a0..934cde5db 100644 --- a/nanobot/cli/tui_launcher.py +++ b/nanobot/cli/tui_launcher.py @@ -17,6 +17,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, cast from nanobot import __version__ +from nanobot.cli.process_identity import named_executable from nanobot.cli.runtime_config import _model_display from nanobot.cli.webui_support import ( _gateway_health_ready, @@ -229,7 +230,12 @@ def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]: detail = (install.stderr or install.stdout).strip().splitlines() suffix = f": {detail[-1]}" if detail else "" raise TuiUnavailableError(f"could not install TUI dependencies{suffix}") - return [bun, str(source_dir / "src" / "index.ts")] + executable = named_executable( + bun, + name="nanobot-tui", + directory=get_data_dir() / "run" / "executables", + ) + return [executable, str(source_dir / "src" / "index.ts")] def _download_release_tui(asset: str) -> Path | None: diff --git a/pyproject.toml b/pyproject.toml index c7f75d1f8..bfab2b573 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "qrcode[pil]>=8.0", "croniter>=6.0.0,<7.0.0", "prompt-toolkit>=3.0.50,<4.0.0", + "setproctitle>=1.3.7,<2.0.0", "questionary>=2.0.0,<3.0.0", "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", diff --git a/tests/cli/test_process_identity.py b/tests/cli/test_process_identity.py new file mode 100644 index 000000000..b539ba530 --- /dev/null +++ b/tests/cli/test_process_identity.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from nanobot.cli.process_identity import named_executable, set_cli_process_identity + + +@pytest.mark.parametrize( + ("args", "expected"), + [ + (["agent"], "nanobot-agent"), + (["gateway", "--background"], "nanobot-gateway"), + (["webui"], "nanobot-webui"), + (["status"], "nanobot"), + ([], "nanobot"), + ], +) +def test_cli_process_identity_uses_product_and_role( + monkeypatch: pytest.MonkeyPatch, + args: list[str], + expected: str, +) -> None: + titles: list[str] = [] + monkeypatch.setattr("nanobot.cli.process_identity.os.name", "posix") + monkeypatch.setattr("nanobot.cli.process_identity.setproctitle", titles.append) + + set_cli_process_identity(args) + + assert titles == [expected] + + +def test_cli_process_identity_keeps_windows_launcher_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + titles: list[str] = [] + monkeypatch.setattr("nanobot.cli.process_identity.os.name", "nt") + monkeypatch.setattr("nanobot.cli.process_identity.setproctitle", titles.append) + + set_cli_process_identity(["agent"]) + + assert titles == [] + + +def test_named_executable_creates_stable_role_symlink( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + executable = tmp_path / "bun" + executable.write_text("runtime", encoding="utf-8") + monkeypatch.setattr("nanobot.cli.process_identity.os.name", "posix") + + first = Path( + named_executable(executable.as_posix(), name="nanobot-tui", directory=tmp_path / "run") + ) + second = Path( + named_executable(executable.as_posix(), name="nanobot-tui", directory=tmp_path / "run") + ) + + assert first == second + assert first.name == "nanobot-tui" + assert first.is_symlink() + assert first.resolve() == executable + + +def test_named_executable_uses_original_on_windows( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr("nanobot.cli.process_identity.os.name", "nt") + + assert ( + named_executable("bun.exe", name="nanobot-tui", directory=tmp_path / "run") + == "bun.exe" + ) diff --git a/tests/cli/test_tui_launcher.py b/tests/cli/test_tui_launcher.py index 6727393be..3d438c6eb 100644 --- a/tests/cli/test_tui_launcher.py +++ b/tests/cli/test_tui_launcher.py @@ -467,9 +467,13 @@ def test_source_checkout_refreshes_locked_tui_dependencies( return subprocess.CompletedProcess(command, 0, "", "") monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install) + monkeypatch.setattr( + "nanobot.cli.tui_launcher.named_executable", + lambda executable, **_kwargs: f"{executable}-named", + ) assert _resolve_source_tui_command(source_dir, bun) == [ - bun, + f"{bun}-named", str(source_dir / "src" / "index.ts"), ]