diff --git a/README.md b/README.md index ac736d4..f6a1cb6 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ with Client.local(local_options={"binary_path": "/path/to/hai-agent-runtime"}) a Inference stays hosted (`HAI_API_KEY`) unless you pass `inference=Inference.self_hosted(url, model=...)` (`from hai_agents_local.runtime import Inference`). +One agent drives this machine's desktop at a time: a second process asking for a `desktop` or `workstation` bridge +fails fast instead of sharing the mouse. `hai local stop` stops every local turn on the machine; so does a double Esc +once `hai_agents_local.killswitch.arm_esc_listener()` is armed (macOS, Windows, and X11 Linux). + ## How a session works A session is one run of an agent against a task. It moves through a small set of states: `pending`, `running`, and then a settled state such as `completed`, `idle`, `failed`, `timed_out`, or `interrupted`. diff --git a/pyproject.toml b/pyproject.toml index 5eab6d7..4d6b085 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hai-agents" -version = "1.2.1" +version = "1.2.2" description = "Python SDK for H Company's Agents API: autonomous agents powered by Holo." requires-python = ">=3.10" readme = "README.md" @@ -34,6 +34,7 @@ dependencies = [ [project.optional-dependencies] cli = [ "python-dotenv>=1.2.3,<2", + "pyyaml>=6.0,<7", "rich>=15.0.0,<16", "typer>=0.27.2,<1", ] @@ -45,6 +46,7 @@ browser = [ ] workstation = [ "hai-drivers[browser-cli,cua-cli,desktop-cli]>=0.1.10", + "pynput>=1.7.6; sys_platform == 'win32'", ] all = [ "hai-agents[browser,cli,desktop,workstation]", diff --git a/src/hai_agents/core/client_wrapper.py b/src/hai_agents/core/client_wrapper.py index f96334c..be22f85 100644 --- a/src/hai_agents/core/client_wrapper.py +++ b/src/hai_agents/core/client_wrapper.py @@ -29,9 +29,9 @@ def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "hai_agents/1.2.1", + "User-Agent": "hai_agents/1.2.2", "X-HCompany-Client-Name": "hai_agents", - "X-HCompany-Client-Version": "1.2.1", + "X-HCompany-Client-Version": "1.2.2", "X-HCompany-Client-Type": "sdk", "X-HCompany-Language": "Python", "X-HCompany-Runtime": f"python/{platform.python_version()}", diff --git a/src/hai_agents_cli/app.py b/src/hai_agents_cli/app.py index 88303ef..a80a4a8 100644 --- a/src/hai_agents_cli/app.py +++ b/src/hai_agents_cli/app.py @@ -604,8 +604,8 @@ def mcp_install( def _install_one(cid: str, c: mcp_hosts.Client, url: str, key: str) -> dict: - status, detail = mcp_hosts.wire_mcp(c, url, key) - skill = mcp_hosts.wire_skill(c) if c.skills_dir is not None else None + status, detail = mcp_hosts.wire_mcp(c, {mcp_hosts.URL: url, mcp_hosts.KEY: key}, secret=key) + skill = mcp_hosts.wire_skill(c, mcp_hosts.SERVER_NAME, mcp_hosts.bundled_skill()) if c.skills_dir else None return {"client": cid, "status": status, "detail": detail, "skill": skill} @@ -792,7 +792,7 @@ def _arm_kill_switch(bridge: Any) -> Any: listener = arm_esc_listener() if listener is not None: console.print(f"[dim]{KILL_SWITCH_ARMED_HINT}[/dim]") - elif sys.platform == "darwin": + else: # A panic button that silently failed to arm is dangerous; always say so. console.print(f"[yellow]{KILL_SWITCH_UNAVAILABLE_HINT}[/yellow]") return listener diff --git a/src/hai_agents_cli/doctor.py b/src/hai_agents_cli/doctor.py index c3d1fa3..fe667a6 100644 --- a/src/hai_agents_cli/doctor.py +++ b/src/hai_agents_cli/doctor.py @@ -1,4 +1,4 @@ -"""`hai doctor`: read-only diagnostics for login, platform access, and local control, with fix-its.""" +"""`hai doctor`: read-only diagnostics for login, platform access, the local runtime, and local control.""" from __future__ import annotations @@ -47,6 +47,15 @@ def check_platform(api_key: str | None, base_url: str | None) -> CheckResult: return CheckResult("platform", True, f"authenticated against {endpoint}") +def check_runtime() -> CheckResult: + from hai_agents_local.runtime import locate_runtime + + path = locate_runtime() + if path is None: + return CheckResult("runtime", True, "local runtime not installed yet; downloaded on the first local run") + return CheckResult("runtime", True, path) + + def check_browser() -> CheckResult: if importlib.util.find_spec("hai_drivers") is None or importlib.util.find_spec("hai_drivers.web") is None: return CheckResult( @@ -104,5 +113,5 @@ def run_checks(api_key: str | None, base_url: str | None) -> list[CheckResult]: checks = [login] if login.ok: checks.append(check_platform(api_key, base_url)) - checks.extend([check_browser(), check_desktop()]) + checks.extend([check_runtime(), check_browser(), check_desktop()]) return checks diff --git a/src/hai_agents_cli/mcp_hosts.py b/src/hai_agents_cli/mcp_hosts.py index a344eab..e3810f8 100644 --- a/src/hai_agents_cli/mcp_hosts.py +++ b/src/hai_agents_cli/mcp_hosts.py @@ -1,4 +1,4 @@ -"""MCP client registry + install dispatch for the remote `hai-agents` server. Add a client = add one `Client`.""" +"""MCP host registry for the remote `hai-agents` server, plus the install engine other CLIs reuse with their own registry.""" from __future__ import annotations @@ -9,6 +9,7 @@ import shutil import subprocess import tempfile +from collections.abc import Callable, Mapping from dataclasses import dataclass from importlib import resources from pathlib import Path, PurePosixPath @@ -19,12 +20,9 @@ DEFAULT_MCP_URL = "https://agp.eu.hcompany.ai/mcp" # Placeholders kept in the registry leaves; substituted with the live endpoint + key at wire time. -_URL = "__MCP_URL__" -_KEY = "__MCP_KEY__" - - -def _bearer_headers() -> dict[str, str]: - return {"Authorization": f"Bearer {_KEY}"} +URL = "__MCP_URL__" +KEY = "__MCP_KEY__" +_BEARER_HEADERS = {"Authorization": f"Bearer {KEY}"} class Status(enum.Enum): @@ -46,7 +44,7 @@ def fatal(self) -> bool: @dataclass(frozen=True) class Client: - """One MCP client: CLI clients set `cli_cmd`; file clients set `config_path` + `key_path` + `leaf`.""" + """One MCP host: CLI hosts set `cli_cmd`; file hosts set `config_path` (JSON or YAML) + `key_path` + `leaf`.""" name: str config_path: str | None = None @@ -54,19 +52,20 @@ class Client: cli_remove_cmds: tuple[tuple[str, ...], ...] = () key_path: tuple[str, ...] | None = None leaf: dict[str, Any] | None = None - skills_dir: str | None = None # under $HOME; None if the client has no SKILL.md auto-load + skills_dir: str | None = None # under $HOME; None if the host has no SKILL.md auto-load + home_marker: str | None = None # under $HOME; its presence proves the host is installed -def _vscode_config_path(app_dir: str) -> str: - """Per-OS user-level VS Code MCP config for the given app directory (`Code`, `Code - Insiders`).""" +def user_config_path(*parts: str) -> str: + """Per-OS user app config: %APPDATA% on Windows, ~/Library/Application Support on macOS, else $XDG_CONFIG_HOME.""" system = platform.system() if system == "Windows": - appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") - return str(Path(appdata) / app_dir / "User" / "mcp.json") - if system == "Darwin": - return f"~/Library/Application Support/{app_dir}/User/mcp.json" - xdg = os.environ.get("XDG_CONFIG_HOME") or "~/.config" - return f"{xdg}/{app_dir}/User/mcp.json" + root = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") + elif system == "Darwin": + root = "~/Library/Application Support" + else: + root = os.environ.get("XDG_CONFIG_HOME") or "~/.config" + return str(Path(root, *parts)) CLIENTS: dict[str, Client] = { @@ -74,20 +73,20 @@ def _vscode_config_path(app_dir: str) -> str: name="Cursor", config_path="~/.cursor/mcp.json", key_path=("mcpServers", SERVER_NAME), - leaf={"url": _URL, "headers": _bearer_headers()}, + leaf={"url": URL, "headers": _BEARER_HEADERS}, skills_dir=".cursor/skills", ), "vscode": Client( name="VS Code", - config_path=_vscode_config_path("Code"), + config_path=user_config_path("Code", "User", "mcp.json"), key_path=("servers", SERVER_NAME), - leaf={"type": "http", "url": _URL, "headers": _bearer_headers()}, + leaf={"type": "http", "url": URL, "headers": _BEARER_HEADERS}, ), "vscode-insiders": Client( name="VS Code Insiders", - config_path=_vscode_config_path("Code - Insiders"), + config_path=user_config_path("Code - Insiders", "User", "mcp.json"), key_path=("servers", SERVER_NAME), - leaf={"type": "http", "url": _URL, "headers": _bearer_headers()}, + leaf={"type": "http", "url": URL, "headers": _BEARER_HEADERS}, ), "claude-code": Client( name="Claude Code", @@ -100,9 +99,9 @@ def _vscode_config_path(app_dir: str) -> str: "--transport", "http", SERVER_NAME, - _URL, + URL, "--header", - f"Authorization: Bearer {_KEY}", + f"Authorization: Bearer {KEY}", ), cli_remove_cmds=( # Clear any prior entry at either writable scope so a rotated key / url can't survive, @@ -112,11 +111,17 @@ def _vscode_config_path(app_dir: str) -> str: ), skills_dir=".claude/skills", ), + "hermes": Client( + name="Hermes", + config_path="~/.hermes/config.yaml", + key_path=("mcp_servers", SERVER_NAME), + leaf={"url": URL, "headers": _BEARER_HEADERS}, + ), "windsurf": Client( name="Windsurf", config_path="~/.codeium/windsurf/mcp_config.json", key_path=("mcpServers", SERVER_NAME), - leaf={"serverUrl": _URL, "headers": _bearer_headers()}, + leaf={"serverUrl": URL, "headers": _BEARER_HEADERS}, ), } @@ -132,8 +137,15 @@ def resolve_mcp_url(base_url: str | None, override: str | None) -> str: return DEFAULT_MCP_URL +def bundled_skill() -> Path: + """The hai-agents SKILL.md directory shipped with this package.""" + return Path(str(resources.files("hai_agents_cli.host_skills").joinpath(SERVER_NAME))) + + def host_present(c: Client) -> bool: - """True if the client looks installed: its CLI is on PATH, or its config directory exists.""" + """True if the host looks installed: its home marker exists, its CLI is on PATH, or its config directory exists.""" + if c.home_marker and (Path.home() / c.home_marker).exists(): + return True if c.cli_cmd is not None: return shutil.which(c.cli_cmd[0]) is not None assert c.config_path is not None @@ -142,33 +154,32 @@ def host_present(c: Client) -> bool: def host_target(c: Client) -> str: - """Where the config lands (~-path), or 'via CLI' for CLI-managed clients.""" - return _home_short(c.config_path) if c.config_path else "via CLI" + """Where the config lands (~-path), or 'via CLI' for CLI-managed hosts.""" + return home_short(c.config_path) if c.config_path else "via CLI" -def wire_skill(c: Client) -> tuple[Status, str]: - """Symlink the bundled hai-agents SKILL.md into `c`'s skills dir so the host auto-loads it.""" +def wire_skill(c: Client, name: str, source: Path) -> tuple[Status, str]: + """Symlink the skill directory `source` into `c`'s skills dir as `name` so the host auto-loads it.""" if c.skills_dir is None: return Status.SKIPPED, "no skill auto-load" home = Path.home() - if not (home / PurePosixPath(c.skills_dir).parts[0]).exists(): - return Status.ABSENT, "client not installed" + if not (home / (c.home_marker or PurePosixPath(c.skills_dir).parts[0])).exists(): + return Status.ABSENT, "host not installed" skills_root = home / c.skills_dir skills_root.mkdir(parents=True, exist_ok=True) - link = skills_root / SERVER_NAME - source = Path(str(resources.files("hai_agents_cli.host_skills").joinpath(SERVER_NAME))) + link = skills_root / name if link.is_symlink(): # strict=False: a reinstall can leave the link dangling at a removed site-packages path. if link.resolve(strict=False) == source.resolve(strict=False): - return Status.SKIPPED, _home_short(str(link)) + return Status.SKIPPED, home_short(str(link)) link.unlink() elif link.exists(): skill_md, src_md = link / "SKILL.md", source / "SKILL.md" is_ours = link.is_dir() and skill_md.exists() if is_ours and src_md.exists() and skill_md.read_bytes() == src_md.read_bytes(): - return Status.SKIPPED, _home_short(str(link)) + return Status.SKIPPED, home_short(str(link)) if not is_ours: - return Status.FAILED, f"{_home_short(str(link))} exists and is not a hai-agents skill" + return Status.FAILED, f"{home_short(str(link))} exists and is not a {name} skill" shutil.rmtree(link) try: link.symlink_to(source, target_is_directory=True) @@ -179,41 +190,44 @@ def wire_skill(c: Client) -> tuple[Status, str]: shutil.copytree(source, link) except OSError as copy_exc: return Status.FAILED, f"{link}: {copy_exc}" - return Status.INSTALLED, f"{_home_short(str(link))} (copy; enable Developer Mode for symlinks)" + return Status.INSTALLED, f"{home_short(str(link))} (copy; enable Developer Mode for symlinks)" return Status.FAILED, f"{link}: {exc}" - return Status.INSTALLED, _home_short(str(link)) + return Status.INSTALLED, home_short(str(link)) -def wire_mcp(c: Client, url: str, key: str) -> tuple[Status, str]: - """Install the server into `c`: a CLI `add`, or a JSON config merge.""" +def wire_mcp(c: Client, substitutions: Mapping[str, str], *, secret: str | None = None) -> tuple[Status, str]: + """Install the server into `c`, filling registry placeholders from `substitutions`; `secret` is masked in errors.""" if c.cli_cmd is not None: - add = [_render(arg, url, key) for arg in c.cli_cmd] - removes = [list(rm) for rm in c.cli_remove_cmds] - return _install_via_cli(add, removes, secret=key) + add = [_render(arg, substitutions) for arg in c.cli_cmd] + removes = [[_render(arg, substitutions) for arg in rm] for rm in c.cli_remove_cmds] + return _install_via_cli(add, removes, secret=secret) assert c.config_path is not None and c.key_path is not None and c.leaf is not None - return _wire_json(Path(c.config_path).expanduser(), c.key_path, _render(c.leaf, url, key)) + try: + return _wire_config(Path(c.config_path).expanduser(), c.key_path, _render(c.leaf, substitutions)) + except OSError as exc: + return Status.FAILED, str(exc) -def _render(obj: Any, url: str, key: str) -> Any: - """Deep-copy `obj`, substituting the URL and key placeholders in every string.""" +def _render(obj: Any, substitutions: Mapping[str, str]) -> Any: + """Deep-copy `obj`, substituting every placeholder in every string.""" if isinstance(obj, str): - return obj.replace(_URL, url).replace(_KEY, key) + for placeholder, value in substitutions.items(): + obj = obj.replace(placeholder, value) + return obj if isinstance(obj, dict): - return {k: _render(v, url, key) for k, v in obj.items()} + return {k: _render(v, substitutions) for k, v in obj.items()} if isinstance(obj, (list, tuple)): - return [_render(x, url, key) for x in obj] + return [_render(x, substitutions) for x in obj] return obj -def _install_via_cli( - add_cmd: list[str], remove_cmds: list[list[str]] | None = None, secret: str | None = None -) -> tuple[Status, str]: +def _install_via_cli(add_cmd: list[str], remove_cmds: list[list[str]], secret: str | None) -> tuple[Status, str]: exe = shutil.which(add_cmd[0]) if exe is None: return Status.ABSENT, f"{add_cmd[0]!r} not on PATH" # `add` refuses to overwrite, so drop any existing entry first; otherwise a rotated key or - # changed url is silently kept. Re-adding always reflects the current url + key. - for rm in remove_cmds or []: + # changed url is silently kept. Re-adding always reflects the current values. + for rm in remove_cmds: subprocess.run([exe, *rm[1:]], capture_output=True, text=True, check=False) try: subprocess.run([exe, *add_cmd[1:]], check=True, capture_output=True, text=True) @@ -226,32 +240,56 @@ def _install_via_cli( return Status.INSTALLED, f"via {add_cmd[0]} CLI" -def _wire_json(path: Path, key_path: tuple[str, ...], leaf: dict[str, Any]) -> tuple[Status, str]: +def _wire_config(path: Path, key_path: tuple[str, ...], leaf: dict[str, Any]) -> tuple[Status, str]: + """Merge `leaf` at `key_path` into a JSON or YAML config, keeping sibling servers and any keys the user added.""" + load: Callable[[str], Any] + dump: Callable[[dict[str, Any]], str] + parse_error: type[Exception] + if path.suffix in (".yaml", ".yml"): + import yaml + + load, parse_error = yaml.safe_load, yaml.YAMLError + + def dump(data: dict[str, Any]) -> str: + return yaml.safe_dump(data, sort_keys=False, default_flow_style=False) + + else: + load, parse_error = json.loads, json.JSONDecodeError + + def dump(data: dict[str, Any]) -> str: + return json.dumps(data, indent=2) + "\n" + path.parent.mkdir(parents=True, exist_ok=True) data: dict[str, Any] = {} if path.exists() and path.stat().st_size > 0: try: - loaded = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - return Status.FAILED, f"{path}: invalid JSON ({exc})" - if not isinstance(loaded, dict): - return Status.FAILED, f"{path}: top-level is not an object" - data = loaded + loaded = load(path.read_text(encoding="utf-8")) + except parse_error as exc: + return Status.FAILED, f"{path}: invalid config ({exc})" + # YAML reads an empty or comments-only file as None. + if loaded is not None and not isinstance(loaded, dict): + return Status.FAILED, f"{path}: top-level is not a mapping" + data = loaded or {} cursor: Any = data for k in key_path[:-1]: cursor = cursor.setdefault(k, {}) if not isinstance(cursor, dict): - return Status.FAILED, f"{path}: {k!r} is not an object" + return Status.FAILED, f"{path}: {k!r} is not a mapping" last = key_path[-1] - if cursor.get(last) == leaf: - return Status.SKIPPED, _home_short(str(path)) - cursor[last] = leaf - _atomic_write_secret(path, json.dumps(data, indent=2) + "\n") - return Status.INSTALLED, _home_short(str(path)) + existing = cursor.get(last) + merged = {**existing, **leaf} if isinstance(existing, dict) else leaf + if existing == merged: + return Status.SKIPPED, home_short(str(path)) + cursor[last] = merged + if path.exists(): + # YAML round-trips drop comments; keep the original recoverable. + shutil.copy2(path, path.with_name(path.name + ".bak")) + _atomic_write_secret(path, dump(data)) + return Status.INSTALLED, home_short(str(path)) def _atomic_write_secret(path: Path, content: str) -> None: - """Write to a sibling temp then `rename` over the target, chmod 600 (the file embeds an API key).""" + """Write to a sibling temp then `rename` over the target, chmod 600 (the file may embed an API key).""" fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) try: with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: @@ -265,6 +303,6 @@ def _atomic_write_secret(path: Path, content: str) -> None: raise -def _home_short(p: str) -> str: +def home_short(p: str) -> str: home = str(Path.home()) return "~" + p[len(home) :] if p.startswith(home) else p diff --git a/src/hai_agents_local/bridge.py b/src/hai_agents_local/bridge.py index 9416a3d..2b2dd00 100644 --- a/src/hai_agents_local/bridge.py +++ b/src/hai_agents_local/bridge.py @@ -17,6 +17,7 @@ import httpx from .config import default_base_url +from .desktop_lock import DesktopClaim from .errors import ChannelClosedError, RateLimitedError, SessionNotFoundError from .runtime import identity from .transport import Command, CommandExchange, Json, deserialize_args, serialize_result @@ -64,6 +65,8 @@ class LocalBridge(ABC, Generic[DriverT]): """Appended to the manager's not-ready timeout error; names the common cause of a hung startup.""" verify_runtime: bool = False """Reject responses not HMAC-proven with api_key; requires api_key to be the local runtime's token string.""" + drives_desktop: ClassVar[bool] = False + """Holds the machine-wide desktop claim while serving, so two agents never share one mouse.""" def __init__( self, @@ -117,9 +120,19 @@ async def interrupt_driver(self) -> None: """Stop run-owned work; drivers without owned processes need no special action.""" async def run(self) -> None: - """Serve commands until stopped; raises AuthError on a bad key.""" + """Serve commands until stopped; raises AuthError on a bad key, DesktopBusyError when another agent holds the desktop.""" # An asyncio.Event binds to the loop it is first awaited on; a restarted bridge runs on a new loop. self._stop_event = asyncio.Event() + claim = DesktopClaim() if self.drives_desktop else None + if claim is not None and not await claim.acquire(self._stop_event): + return + try: + await self._serve() + finally: + if claim is not None: + claim.release() + + async def _serve(self) -> None: options: dict[str, Any] = { "headers": {"Accept": "application/json"}, "auth": _BearerAuth(self.api_key), diff --git a/src/hai_agents_local/desktop.py b/src/hai_agents_local/desktop.py index 0339b98..51dd528 100644 --- a/src/hai_agents_local/desktop.py +++ b/src/hai_agents_local/desktop.py @@ -54,6 +54,7 @@ class PyautoguiDesktopBridge(LocalBridge["DesktopDriverInterface"]): """ environment_kind = "desktop" + drives_desktop = True def __init__( self, diff --git a/src/hai_agents_local/desktop_lock.py b/src/hai_agents_local/desktop_lock.py new file mode 100644 index 0000000..bdb0c7e --- /dev/null +++ b/src/hai_agents_local/desktop_lock.py @@ -0,0 +1,105 @@ +"""Machine-wide desktop claim: at most one process drives this machine's mouse and keyboard at a time.""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import sys +import threading +import time + +from .killswitch import STOP_PATH + +LOCK_PATH = STOP_PATH.parent / "desktop.lock" +# Covers a previous agent process that is still shutting down. +CLAIM_GRACE_S = 10.0 +CLAIM_POLL_S = 0.1 + +if sys.platform == "win32": + import msvcrt + + def _try_lock(fd: int) -> bool: + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + except OSError: + return False + return True + + def _unlock(fd: int) -> None: + with contextlib.suppress(OSError): + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + +else: + import fcntl + + def _try_lock(fd: int) -> bool: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + return False + return True + + def _unlock(fd: int) -> None: + with contextlib.suppress(OSError): + fcntl.flock(fd, fcntl.LOCK_UN) + + +class DesktopBusyError(RuntimeError): + """Another process is driving this machine's desktop.""" + + +_process_lock = threading.Lock() +_holders = 0 +_fd: int | None = None + + +def _claim_for_process() -> bool: + global _holders, _fd + with _process_lock: + if _holders == 0: + LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(LOCK_PATH, os.O_RDWR | os.O_CREAT, 0o600) + if not _try_lock(fd): + os.close(fd) + return False + _fd = fd + _holders += 1 + return True + + +def _release_for_process() -> None: + global _holders, _fd + with _process_lock: + _holders -= 1 + if _holders == 0 and _fd is not None: + _unlock(_fd) + os.close(_fd) + _fd = None + + +class DesktopClaim: + """One bridge's share of this process's desktop lock; bridges in one process share it, other processes wait.""" + + def __init__(self) -> None: + self._held = False + + async def acquire(self, stop: asyncio.Event) -> bool: + """Wait up to CLAIM_GRACE_S for the desktop; False when ``stop`` fires first, DesktopBusyError on timeout.""" + deadline = time.monotonic() + CLAIM_GRACE_S + while not _claim_for_process(): + if stop.is_set(): + return False + if time.monotonic() >= deadline: + raise DesktopBusyError( + "another agent is driving this desktop; wait for it to finish or stop it with `hai local stop`" + ) + with contextlib.suppress(asyncio.TimeoutError, TimeoutError): + await asyncio.wait_for(stop.wait(), CLAIM_POLL_S) + self._held = True + return True + + def release(self) -> None: + if self._held: + self._held = False + _release_for_process() diff --git a/src/hai_agents_local/killswitch.py b/src/hai_agents_local/killswitch.py index 19e5598..56049f6 100644 --- a/src/hai_agents_local/killswitch.py +++ b/src/hai_agents_local/killswitch.py @@ -1,4 +1,4 @@ -"""Out-of-band kill switch: a cross-process stop file, plus a global double-Esc listener on macOS. +"""Out-of-band kill switch: a cross-process stop file, plus a global double-Esc listener. Ctrl-C is not a reliable panic button while an agent drives the mouse and keyboard: the terminal may not have focus, and focus itself is what the agent is fighting you for. The stop file works @@ -9,10 +9,11 @@ import logging import os +import sys import threading import time from pathlib import Path -from typing import Any, Callable +from typing import Any, Callable, Protocol logger = logging.getLogger(__name__) @@ -26,10 +27,15 @@ TAP_STOP_JOIN_TIMEOUT_S = 2.0 KILL_SWITCH_ARMED_HINT = "kill switch armed: press Esc twice fast to stop" -KILL_SWITCH_UNAVAILABLE_HINT = ( - "double-Esc kill switch unavailable; grant Input Monitoring to this terminal in " - "System Settings -> Privacy & Security, or stop with `hai local stop`" -) +_UNAVAILABLE_REASON = { + "darwin": "grant Input Monitoring to this terminal in System Settings -> Privacy & Security", + "win32": "the global keyboard hook did not start", +}.get(sys.platform, "needs `pip install pynput` and an X11 session; Wayland has no global key listener") +KILL_SWITCH_UNAVAILABLE_HINT = f"double-Esc kill switch unavailable ({_UNAVAILABLE_REASON}); stop with `hai local stop`" + + +class EscListener(Protocol): + def stop(self) -> None: ... def request_stop(now: float | None = None) -> None: @@ -38,6 +44,15 @@ def request_stop(now: float | None = None) -> None: STOP_PATH.write_text(str(now if now is not None else time.time()), encoding="utf-8") +def _stop_on_double_esc() -> None: + """Never raises: an exception in a key-listener callback silently disarms the kill switch.""" + logger.warning("double-Esc detected; requesting stop") + try: + request_stop() + except Exception: + logger.warning("could not file the kill-switch stop", exc_info=True) + + class StopSentinel: """Reads the shared stop file; only stops filed after ``started_at`` count, so stale files are inert.""" @@ -169,18 +184,54 @@ def _handler(self, proxy: Any, event_type: int, event: Any, refcon: Any) -> Any: and quartz.CGEventGetIntegerValueField(event, quartz.kCGKeyboardEventKeycode) == ESC_KEYCODE and self._detector.record(time.monotonic()) ): - logger.warning("double-Esc detected; requesting stop") - request_stop() + _stop_on_double_esc() except Exception: logger.warning("Esc kill-switch handler failed", exc_info=True) return event -def arm_esc_listener() -> QuartzEscTap | None: - """Arm the global double-Esc listener; None when unsupported here (non-macOS) or not permitted.""" - import sys +class PynputEscListener: + """Global double-Esc listener on Windows and X11 Linux that files a stop.""" + + def __init__(self) -> None: + self._detector = MultiTapDetector() + self._listener: Any = None + + def start(self) -> bool: + """False when pynput or a global key hook is unavailable (no display, Wayland).""" + try: + from pynput import keyboard + except Exception: + logger.debug("pynput is unavailable; the Esc kill switch is disabled", exc_info=True) + return False + + def on_press(key: Any) -> None: + if key == keyboard.Key.esc and self._detector.record(time.monotonic()): + _stop_on_double_esc() + + listener = keyboard.Listener(on_press=on_press) + try: + listener.start() + except Exception: + logger.debug("could not start the pynput key listener", exc_info=True) + return False + # `Listener.wait` has no timeout and never returns if the hook dies during setup. + ready = threading.Thread(target=listener.wait, daemon=True, name="hai-esc-listener-ready") + ready.start() + ready.join(TAP_START_TIMEOUT_S) + if ready.is_alive() or not listener.running: + listener.stop() + return False + self._listener = listener + return True + + def stop(self) -> None: + if self._listener is not None: + self._listener.stop() + self._listener = None + - if sys.platform != "darwin": - return None - tap = QuartzEscTap() - return tap if tap.start() else None +def arm_esc_listener() -> EscListener | None: + """Arm the global double-Esc listener; None when unsupported here or not permitted.""" + listener: QuartzEscTap | PynputEscListener = QuartzEscTap() if sys.platform == "darwin" else PynputEscListener() + return listener if listener.start() else None diff --git a/src/hai_agents_local/manager.py b/src/hai_agents_local/manager.py index 61c83cf..46e3cc2 100644 --- a/src/hai_agents_local/manager.py +++ b/src/hai_agents_local/manager.py @@ -71,7 +71,8 @@ def _ensure_one(self, bridge: LocalBridge) -> bool: ) if runner.error is not None: raise RuntimeError( - f"local {bridge.environment_kind} bridge for environment {bridge.environment_id!r} failed to start" + f"local {bridge.environment_kind} bridge for environment {bridge.environment_id!r} " + f"failed to start: {runner.error}" ) from runner.error except BaseException: if started: @@ -152,7 +153,8 @@ def _serve(self) -> None: self.error = RuntimeError("bridge was stopped before it became ready") except Exception as exc: self.error = exc - if not sys.is_finalizing() and threading.main_thread().is_alive(): + # Startup failures surface to the ensure() caller; only a crash after startup is logged here. + if self.bridge.ready.is_set() and not sys.is_finalizing() and threading.main_thread().is_alive(): logger.exception("local %s bridge crashed", self.bridge.environment_kind) self.notify_lost() finally: diff --git a/src/hai_agents_local/runtime/__init__.py b/src/hai_agents_local/runtime/__init__.py index 65a16b9..2cdaad2 100644 --- a/src/hai_agents_local/runtime/__init__.py +++ b/src/hai_agents_local/runtime/__init__.py @@ -10,7 +10,7 @@ RuntimeUnhealthyError, ) from .inference import Inference -from .runtime import LocalRuntime +from .runtime import LocalRuntime, locate_runtime __all__ = [ "BinaryIncompatibleError", @@ -23,4 +23,5 @@ "RuntimeUnhealthyError", "acquire_runtime", "acquire_runtime_async", + "locate_runtime", ] diff --git a/src/hai_agents_local/runtime/acquire.py b/src/hai_agents_local/runtime/acquire.py index 3c4a0a8..9040baf 100644 --- a/src/hai_agents_local/runtime/acquire.py +++ b/src/hai_agents_local/runtime/acquire.py @@ -74,8 +74,10 @@ def _launch_options( def _claim(runtime: LocalRuntime, inference: typing.Optional[Inference]) -> typing.Tuple[LocalRuntime, bool]: """The runtime to use and whether this process owns it; an attach to our own live child returns that child.""" - if inference is not None and not runtime.owned: - raise ValueError("inference selection cannot reconfigure an existing runtime; choose a free local port") + if inference is not None and not runtime.owned and runtime.serves is None: + raise ValueError( + "a runtime of unknown inference already listens there; stop it or set HAI_AGENT_RUNTIME_PORT to another port" + ) with _spawned_lock: if runtime.owned: _spawned_here[runtime.base_url] = runtime diff --git a/src/hai_agents_local/runtime/inference.py b/src/hai_agents_local/runtime/inference.py index c1eac1a..734d161 100644 --- a/src/hai_agents_local/runtime/inference.py +++ b/src/hai_agents_local/runtime/inference.py @@ -4,9 +4,13 @@ import os from dataclasses import dataclass -from typing import Dict, Optional +from typing import Dict, Mapping, Optional from urllib.parse import urlsplit +URL_ENV = "HAI_AGENT_RUNTIME_BASE_URL" +MODEL_ENV = "HAI_AGENT_RUNTIME_MODEL" +HOSTED = "hosted" + @dataclass(frozen=True) class Inference: @@ -29,9 +33,16 @@ def runtime_env(self, overrides: Optional[Dict[str, str]] = None) -> Dict[str, s if self.base_url is not None: # Never forward a hosted inference credential to a user-selected endpoint. env.pop("HAI_API_KEY", None) - env["HAI_AGENT_RUNTIME_BASE_URL"] = self.base_url + env[URL_ENV] = self.base_url else: - env.pop("HAI_AGENT_RUNTIME_BASE_URL", None) + env.pop(URL_ENV, None) if self.model is not None: - env["HAI_AGENT_RUNTIME_MODEL"] = self.model + env[MODEL_ENV] = self.model return env + + +def served_inference(env: Mapping[str, str]) -> str: + """What a runtime started with ``env`` infers against: ``hosted`` or a server URL, then any default model.""" + url = env.get(URL_ENV, "").strip() + model = env.get(MODEL_ENV, "").strip() + return " ".join(part for part in (url or HOSTED, model) if part) diff --git a/src/hai_agents_local/runtime/runtime.py b/src/hai_agents_local/runtime/runtime.py index aeaa4f5..f9d224d 100644 --- a/src/hai_agents_local/runtime/runtime.py +++ b/src/hai_agents_local/runtime/runtime.py @@ -4,6 +4,7 @@ import asyncio import contextlib +import hashlib import logging import os import pathlib @@ -26,6 +27,7 @@ LocalRuntimeError, RuntimeUnhealthyError, ) +from .inference import HOSTED, served_inference from .install import DOWNLOAD_SHA256_ENV, DOWNLOAD_URL_ENV, install_runtime, installed_binary, pinned_artifact from .manifest import PINNED_RUNTIME_VERSION from .process import ( @@ -41,6 +43,7 @@ ) from .state import ( DEFAULT_PORT, + inference_file_path, pid_file_path, read_pid, read_state_file, @@ -60,6 +63,7 @@ AUTH_TOKEN_ENV = "HAI_AGENT_RUNTIME_API_TOKEN" CLIENT_TIMEOUT_S = 60.0 IDLE_PROBE_PAGE_SIZE = 50 +INFERENCE_PORTS = 1000 # Beyond the holder's health budget: its authenticated probe, then a failed child's graceful stop. STARTUP_LOCK_GRACE_S = TERM_GRACE_S + KILL_WAIT_S + 5.0 @@ -81,6 +85,22 @@ def _port_of(base_url: str) -> int: return urlsplit(base_url).port or DEFAULT_PORT +def _inference_record(served: str, pid: int) -> str: + """The pid keeps each runtime's record unique, so a stopping runtime never unlinks its successor's.""" + return f"{served}\n{pid}" + + +def _served_from_record(record: typing.Optional[str]) -> typing.Optional[str]: + return record.splitlines()[0] if record else None + + +def default_port(served: str) -> int: + """Hosted inference on ``DEFAULT_PORT``; any other inference on its own stable port, so both run side by side.""" + if served == HOSTED: + return DEFAULT_PORT + return DEFAULT_PORT + 1 + int(hashlib.sha256(served.encode()).hexdigest(), 16) % INFERENCE_PORTS + + def _authenticated_probe(base_url: str, token: str) -> int: """Status of a proven, bearer-authenticated session listing; call only after /health proved the server.""" with identity.http_client(token, timeout=2.0) as client: @@ -110,6 +130,8 @@ def __init__( proc: typing.Optional[subprocess.Popen] = None, token_file: typing.Optional[pathlib.Path] = None, pid_file: typing.Optional[pathlib.Path] = None, + serves: typing.Optional[str] = None, + inference_file: typing.Optional[pathlib.Path] = None, ) -> None: self.base_url = base_url self.api_key = api_key @@ -117,12 +139,15 @@ def __init__( self.version = version self.log_path = log_path self.owned = owned + # None for a runtime started before spawners recorded their inference. + self.serves = serves self._cache_dir = cache_dir self._port = port self._proc = proc # Set only on the spawner that published generated state; attachers never own the files. self._token_file = token_file self._pid_file = pid_file + self._inference_file = inference_file @classmethod def ensure_started( @@ -142,6 +167,7 @@ def ensure_started( ) -> "LocalRuntime": """Return a reachable LocalRuntime, attaching to an existing one or spawning the binary.""" resolved_cache = resolve_cache_dir(cache_dir) + served = served_inference(cls._child_env(port=0, token="", spawn_env=spawn_env, inherit_env=inherit_env)) base_override = os.environ.get(BASE_URL_ENV, "").strip() if base_override: attached = cls._attach(base_url=base_override.rstrip("/"), cache_dir=resolved_cache) @@ -149,10 +175,9 @@ def ensure_started( raise RuntimeUnhealthyError( f"{BASE_URL_ENV} is set to {base_override} but /health is not answering there" ) - attached.require_recipe(required_recipe) - return attached + return attached._verified(required_recipe, served) - resolved_port = port if port is not None else int(os.environ.get(PORT_ENV, "").strip() or DEFAULT_PORT) + resolved_port = port if port is not None else int(os.environ.get(PORT_ENV, "").strip() or default_port(served)) base_url = f"http://{LOOPBACK_HOST}:{resolved_port}" lock_wait_s = timeout_s + STARTUP_LOCK_GRACE_S try: @@ -162,8 +187,7 @@ def ensure_started( with _startup_lock(resolved_cache, resolved_port, lock_wait_s): attached = cls._attach(base_url=base_url, cache_dir=resolved_cache) if attached is not None: - attached.require_recipe(required_recipe) - return attached + return attached._verified(required_recipe, served) if command is not None and (not command or binary_path is not None): raise ValueError("command must be nonempty and cannot be combined with binary_path") @@ -178,8 +202,7 @@ def ensure_started( with _startup_lock(resolved_cache, resolved_port, lock_wait_s): attached = cls._attach(base_url=base_url, cache_dir=resolved_cache) if attached is not None: - attached.require_recipe(required_recipe) - return attached + return attached._verified(required_recipe, served) if _cancel_event is not None and _cancel_event.is_set(): raise RuntimeUnhealthyError("runtime startup cancelled") @@ -190,6 +213,7 @@ def ensure_started( log_path = runtime_log_path(resolved_port, cache_dir=resolved_cache) proc = None token_file = None + inference_file = None try: proc = spawn( cmd, @@ -209,13 +233,18 @@ def ensure_started( # Published only once the child proved it owns the port, so another runtime's file is never replaced. if not explicit_token: token_file = write_owner_only(token_file_path(resolved_port, cache_dir=resolved_cache), token) + inference_file = write_owner_only( + inference_file_path(resolved_port, cache_dir=resolved_cache), _inference_record(served, proc.pid) + ) pid_file = write_owner_only(pid_file_path(resolved_port, cache_dir=resolved_cache), str(proc.pid)) except BaseException: - # Covers KeyboardInterrupt mid-spawn: never leak the child or its token file. + # Covers KeyboardInterrupt mid-spawn: never leak the child or its state files. if proc is not None: terminate(proc) if token_file is not None: unlink_if_content(token_file, token) + if inference_file is not None and proc is not None: + unlink_if_content(inference_file, _inference_record(served, proc.pid)) raise reported = payload.get("version") reported_version = reported if isinstance(reported, str) else None @@ -232,6 +261,8 @@ def ensure_started( proc=proc, token_file=token_file, pid_file=pid_file, + serves=served, + inference_file=inference_file, ) @classmethod @@ -260,6 +291,16 @@ def require_recipe(self, recipe: typing.Optional[str]) -> None: f"runtime must support recipe {recipe!r}; use a compatible source command or binary" ) + def _verified(self, recipe: typing.Optional[str], served: str) -> "LocalRuntime": + """This runtime, once it supports ``recipe`` and was not started for another inference than ``served``.""" + self.require_recipe(recipe) + if self.serves is not None and self.serves != served: + raise LocalRuntimeError( + f"the runtime at {self.base_url} infers against {self.serves}, not {served}; " + f"set {PORT_ENV} to run another one" + ) + return self + @classmethod def attach( cls, *, port: typing.Optional[int] = None, cache_dir: typing.Optional[_PathInput] = None @@ -311,6 +352,7 @@ def _attach(cls, *, base_url: str, cache_dir: pathlib.Path) -> typing.Optional[" owned=False, cache_dir=cache_dir, port=port, + serves=_served_from_record(read_state_file(inference_file_path(port, cache_dir=cache_dir))), ) @staticmethod @@ -437,6 +479,20 @@ def _cleanup_state_files(self) -> None: if self._pid_file is not None: unlink_if_content(self._pid_file, str(self.pid)) self._pid_file = None + if self._inference_file is not None and self.serves is not None and self.pid is not None: + unlink_if_content(self._inference_file, _inference_record(self.serves, self.pid)) + self._inference_file = None + + +def locate_runtime() -> typing.Optional[str]: + """The binary ``Client.local`` would start, without downloading; None when it would download first.""" + try: + command = LocalRuntime._resolve_command( + binary_path=None, version=None, cache_dir=resolve_cache_dir(), download=False + ) + except BinaryNotFoundError: + return None + return command[0] _held_startup_locks = threading.local() diff --git a/src/hai_agents_local/runtime/state.py b/src/hai_agents_local/runtime/state.py index 175258b..9208b33 100644 --- a/src/hai_agents_local/runtime/state.py +++ b/src/hai_agents_local/runtime/state.py @@ -39,6 +39,11 @@ def pid_file_path(port: int, *, cache_dir: typing.Optional[_PathInput] = None) - return state_dir(cache_dir) / f"agent-pid-{port}" +def inference_file_path(port: int, *, cache_dir: typing.Optional[_PathInput] = None) -> pathlib.Path: + """Where a spawner records the inference its runtime serves, so attachers never borrow another's.""" + return state_dir(cache_dir) / f"agent-inference-{port}" + + def runtime_log_path(port: int, *, cache_dir: typing.Optional[_PathInput] = None) -> pathlib.Path: """Where the runtime spawned on `port` writes its stderr.""" return resolve_cache_dir(cache_dir) / "logs" / f"hai-agent-runtime-{port}.log" diff --git a/src/hai_agents_local/sessions.py b/src/hai_agents_local/sessions.py index 8a75af7..b866425 100644 --- a/src/hai_agents_local/sessions.py +++ b/src/hai_agents_local/sessions.py @@ -226,6 +226,18 @@ def _cancel_sessions_at_exit() -> None: STOPPED_CANCEL_STATUSES = frozenset({404, 409}) +@contextlib.contextmanager +def _confirming_stop(session_id: str) -> typing.Iterator[None]: + """Keep the exit retry registered until the platform confirms the session stopped or had already ended.""" + try: + yield + except ApiError as error: + if error.status_code in STOPPED_CANCEL_STATUSES: + _deregister_exit_cancel(session_id) + raise + _deregister_exit_cancel(session_id) + + class _CloseFailures: """Cancel errors collected while closing; a session that already ended is not a failure.""" @@ -233,14 +245,12 @@ def __init__(self) -> None: self._errors: typing.List[Exception] = [] @contextlib.contextmanager - def cancelling(self, session_id: str) -> typing.Iterator[None]: + def cancelling(self) -> typing.Iterator[None]: try: yield except ApiError as error: if error.status_code not in STOPPED_CANCEL_STATUSES: self._errors.append(error) - else: - _deregister_exit_cancel(session_id) except Exception as error: self._errors.append(error) @@ -282,7 +292,7 @@ class LocalSessionsClient(_LocalSessionsState, SessionsClient): def close(self) -> None: failures = _CloseFailures() for session_id in self._live_sessions(): - with failures.cancelling(session_id): + with failures.cancelling(): self.cancel_session(session_id) failures.raise_any() @@ -294,9 +304,8 @@ def cancel_session(self, id: str, *, request_options: typing.Optional[RequestOpt stop_bridges(owned) self._owned_bridges.pop(str(id), None) finally: - super().cancel_session(id, request_options=request_options) - # Keep the exit retry registered until cancellation is confirmed. - _deregister_exit_cancel(str(id)) + with _confirming_stop(str(id)): + super().cancel_session(id, request_options=request_options) @functools.wraps(SessionsClient.create_session) def create_session(self, **kwargs: typing.Any) -> typing.Any: @@ -329,7 +338,7 @@ class LocalAsyncSessionsClient(_LocalSessionsState, AsyncSessionsClient): async def aclose(self) -> None: failures = _CloseFailures() for session_id in self._live_sessions(): - with failures.cancelling(session_id): + with failures.cancelling(): await self.cancel_session(session_id) failures.raise_any() @@ -340,8 +349,8 @@ async def cancel_session(self, id: str, *, request_options: typing.Optional[Requ await asyncio.to_thread(stop_bridges, owned) self._owned_bridges.pop(str(id), None) finally: - await super().cancel_session(id, request_options=request_options) - _deregister_exit_cancel(str(id)) + with _confirming_stop(str(id)): + await super().cancel_session(id, request_options=request_options) @functools.wraps(AsyncSessionsClient.create_session) async def create_session(self, **kwargs: typing.Any) -> typing.Any: diff --git a/src/hai_agents_local/workstation.py b/src/hai_agents_local/workstation.py index 6b78a1d..8d11332 100644 --- a/src/hai_agents_local/workstation.py +++ b/src/hai_agents_local/workstation.py @@ -28,6 +28,7 @@ class WorkstationBridge(LocalBridge["ManagedCodeSandboxInterface"]): """Serves workstation environments with a shell in ~/hai/, running as the current user.""" environment_kind = "workstation" + drives_desktop = True def __init__( self, diff --git a/tests/conftest.py b/tests/conftest.py index 2cbc2eb..1124348 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,14 @@ import pytest +from hai_agents_local import desktop_lock from hai_agents_local.config import AUTO_BRIDGE_ENV_VAR @pytest.fixture(autouse=True) def _no_auto_bridges(monkeypatch): monkeypatch.setenv(AUTO_BRIDGE_ENV_VAR, "0") + + +@pytest.fixture(autouse=True) +def _isolated_desktop_lock(monkeypatch, tmp_path): + monkeypatch.setattr(desktop_lock, "LOCK_PATH", tmp_path / "desktop.lock") diff --git a/tests/test_local.py b/tests/test_local.py index 408ff3e..5b3ed05 100644 --- a/tests/test_local.py +++ b/tests/test_local.py @@ -10,6 +10,7 @@ import pytest from hai_agents import Client +from hai_agents.core.api_error import ApiError from hai_agents.sessions.client import SessionsClient from hai_agents.types import Desktop, Workstation from hai_agents_local import ( @@ -771,6 +772,36 @@ class BrowserServingBridge(ServingBridge): environment_kind = "web" +class DesktopDrivingBridge(FakeBridge): + drives_desktop = True + + +class WorkstationDrivingBridge(DesktopDrivingBridge): + environment_kind = "workstation" + + +_HOLD_DESKTOP = """ +import asyncio, pathlib, sys, time +from hai_agents_local import desktop_lock +desktop_lock.LOCK_PATH = pathlib.Path(sys.argv[1]) +assert asyncio.run(desktop_lock.DesktopClaim().acquire(asyncio.Event())) +print("held", flush=True) +time.sleep(60) +""" + + +class IdleExchange: + def __init__(self, client: Any, base_url: str) -> None: + pass + + async def ensure_channel(self, session_id: str) -> None: + pass + + async def fetch_commands(self, session_id: str, **kwargs: Any) -> None: + await asyncio.sleep(0.01) + return None + + class TestManager: @pytest.fixture def manager(self): @@ -870,18 +901,29 @@ def test_two_same_kind_bridges_in_one_batch_raise(self, manager): ) assert manager._runners == {} - def test_restarted_bridge_serves_again_on_a_fresh_loop(self, manager, monkeypatch): - class IdleExchange: - def __init__(self, client: Any, base_url: str) -> None: - pass + def test_one_process_drives_the_desktop(self, manager, monkeypatch): + import subprocess + import sys - async def ensure_channel(self, session_id: str) -> None: - pass + import hai_agents_local.bridge as bridge_module + from hai_agents_local import desktop_lock - async def fetch_commands(self, session_id: str, **kwargs: Any) -> None: - await asyncio.sleep(0.01) - return None + monkeypatch.setattr(bridge_module, "CommandExchange", IdleExchange) + monkeypatch.setattr(desktop_lock, "CLAIM_GRACE_S", 0.5) + holder = subprocess.Popen( + [sys.executable, "-c", _HOLD_DESKTOP, str(desktop_lock.LOCK_PATH)], stdout=subprocess.PIPE, text=True + ) + try: + assert holder.stdout is not None and holder.stdout.readline().strip() == "held" + with pytest.raises(RuntimeError, match="another agent is driving this desktop"): + manager.ensure([DesktopDrivingBridge(api_key="k")]) + finally: + holder.kill() + holder.wait() + desktop, workstation = DesktopDrivingBridge(api_key="k"), WorkstationDrivingBridge(api_key="k") + assert manager.ensure([desktop, workstation]) == [desktop.session_id, workstation.session_id] + def test_restarted_bridge_serves_again_on_a_fresh_loop(self, manager, monkeypatch): import hai_agents_local.bridge as bridge_module monkeypatch.setattr(bridge_module, "CommandExchange", IdleExchange) @@ -944,6 +986,54 @@ def test_double_tap_fires_only_within_the_window_then_resets(self): assert detector.record(2.3) assert not detector.record(2.4) + def test_double_esc_files_a_stop_off_macos(self, monkeypatch, tmp_path): + from hai_agents_local import killswitch + + listeners: list[Any] = [] + + class FakeListener: + running = False + + def __init__(self, on_press: Any) -> None: + self.on_press = on_press + listeners.append(self) + + def start(self) -> None: + self.running = True + + def wait(self) -> None: + pass + + def stop(self) -> None: + self.running = False + + keyboard = types.SimpleNamespace(Key=types.SimpleNamespace(esc="esc"), Listener=FakeListener) + monkeypatch.setitem(sys.modules, "pynput", types.SimpleNamespace(keyboard=keyboard)) + monkeypatch.setattr(killswitch, "STOP_PATH", tmp_path / "stop") + monkeypatch.setattr(sys, "platform", "win32") + + listener = killswitch.arm_esc_listener() + assert listener is not None + (fake,) = listeners + fake.on_press("esc") + assert not (tmp_path / "stop").exists() + fake.on_press("esc") + assert (tmp_path / "stop").exists() + listener.stop() + assert not fake.running + + # pynput stops a listener whose callback raises; an unwritable stop file must not disarm it. + monkeypatch.setattr(killswitch, "STOP_PATH", tmp_path / "stop" / "unwritable") + fake.on_press("esc") + fake.on_press("esc") + + never_ready = threading.Event() + monkeypatch.setattr(FakeListener, "wait", lambda self: never_ready.wait()) + monkeypatch.setattr(killswitch, "TAP_START_TIMEOUT_S", 0.2) + assert killswitch.arm_esc_listener() is None + assert not listeners[-1].running + never_ready.set() + def test_only_stops_filed_after_start_count(self, monkeypatch, tmp_path): from hai_agents_local import killswitch @@ -1051,7 +1141,11 @@ def request_stop(self): @pytest.mark.asyncio @pytest.mark.parametrize("asynchronous", [False, True]) -async def test_failed_api_cancel_keeps_interpreter_exit_retry(monkeypatch, asynchronous): +@pytest.mark.parametrize( + ("error", "retried_at_exit"), + [(RuntimeError("remote cancel unavailable"), ["run"]), (ApiError(status_code=409, body="ended"), [])], +) +async def test_exit_retry_outlives_only_an_unconfirmed_cancel(monkeypatch, asynchronous, error, retried_at_exit): from hai_agents import AsyncClient from hai_agents.sessions.client import AsyncSessionsClient from hai_agents_local import sessions as module @@ -1060,7 +1154,7 @@ async def test_failed_api_cancel_keeps_interpreter_exit_retry(monkeypatch, async monkeypatch.setattr(module, "_exit_cancels", {"run": lambda: retried.append("run")}) def cancel(*args, **kwargs): - raise RuntimeError("remote cancel unavailable") + raise error async def async_cancel(*args, **kwargs): cancel() @@ -1069,13 +1163,13 @@ async def async_cancel(*args, **kwargs): monkeypatch.setattr(AsyncSessionsClient, "cancel_session", async_cancel) client = (AsyncClient if asynchronous else Client)(api_key=API_KEY) try: - with pytest.raises(RuntimeError, match="remote cancel unavailable"): + with pytest.raises(type(error)): if asynchronous: await client.sessions.cancel_session("run") else: client.sessions.cancel_session("run") module._cancel_sessions_at_exit() - assert retried == ["run"] + assert retried == retried_at_exit finally: if asynchronous: await client.aclose() diff --git a/tests/test_mcp_install.py b/tests/test_mcp_install.py index 042d3ec..25274e1 100644 --- a/tests/test_mcp_install.py +++ b/tests/test_mcp_install.py @@ -4,6 +4,7 @@ import stat import subprocess +import yaml from typer.testing import CliRunner from hai_agents_cli import mcp_hosts @@ -21,34 +22,60 @@ def test_resolve_mcp_url_prefers_override_then_base_origin_then_eu() -> None: def test_registry_leaves_carry_bearer_and_client_specific_url_key() -> None: rendered = { - cid: mcp_hosts._render(c.leaf, "https://u/mcp", "hk-1") for cid, c in mcp_hosts.CLIENTS.items() if c.leaf + cid: mcp_hosts._render(c.leaf, _subs("https://u/mcp", "hk-1")) for cid, c in mcp_hosts.CLIENTS.items() if c.leaf } assert rendered["cursor"] == {"url": "https://u/mcp", "headers": {"Authorization": "Bearer hk-1"}} assert rendered["vscode"]["type"] == "http" and rendered["vscode"]["url"] == "https://u/mcp" assert rendered["windsurf"]["serverUrl"] == "https://u/mcp" + assert rendered["hermes"] == rendered["cursor"] + + +def _subs(url: str, key: str) -> dict[str, str]: + return {mcp_hosts.URL: url, mcp_hosts.KEY: key} + + +_LEAF = {"url": mcp_hosts.URL, "headers": {"Authorization": f"Bearer {mcp_hosts.KEY}"}} def test_wire_json_merges_preserves_and_is_idempotent(tmp_path) -> None: path = tmp_path / "mcp.json" - path.write_text(json.dumps({"mcpServers": {"other": {"url": "keep"}}}), encoding="utf-8") - c = Client( - name="X", - config_path=str(path), - key_path=("mcpServers", "hai-agents"), - leaf={"url": mcp_hosts._URL, "headers": {"Authorization": f"Bearer {mcp_hosts._KEY}"}}, + path.write_text( + json.dumps({"mcpServers": {"other": {"url": "keep"}, "hai-agents": {"url": "old", "env": {"A": "1"}}}}), + encoding="utf-8", ) + c = Client(name="X", config_path=str(path), key_path=("mcpServers", "hai-agents"), leaf=_LEAF) - status, _ = wire_mcp(c, "https://u/mcp", "hk-secret") + status, _ = wire_mcp(c, _subs("https://u/mcp", "hk-secret"), secret="hk-secret") assert status is Status.INSTALLED data = json.loads(path.read_text()) assert data["mcpServers"]["other"] == {"url": "keep"} assert data["mcpServers"]["hai-agents"] == { "url": "https://u/mcp", + "env": {"A": "1"}, "headers": {"Authorization": "Bearer hk-secret"}, } assert stat.S_IMODE(path.stat().st_mode) == 0o600 - assert wire_mcp(c, "https://u/mcp", "hk-secret")[0] is Status.SKIPPED + assert wire_mcp(c, _subs("https://u/mcp", "hk-secret"))[0] is Status.SKIPPED + + +def test_wire_yaml_merges_and_backs_up_the_original(tmp_path) -> None: + path = tmp_path / "config.yaml" + path.write_text("# user comment\nmodel: hermes-4\nmcp_servers:\n other:\n url: keep\n", encoding="utf-8") + c = Client(name="X", config_path=str(path), key_path=("mcp_servers", "hai-agents"), leaf=_LEAF) + + assert wire_mcp(c, _subs("https://u/mcp", "hk-1"))[0] is Status.INSTALLED + data = yaml.safe_load(path.read_text()) + assert data["model"] == "hermes-4" and data["mcp_servers"]["other"] == {"url": "keep"} + assert data["mcp_servers"]["hai-agents"]["url"] == "https://u/mcp" + assert "# user comment" in (tmp_path / "config.yaml.bak").read_text() + + +def test_unwritable_config_fails_that_host_only(tmp_path) -> None: + (tmp_path / "not-a-dir").write_text("", encoding="utf-8") + c = Client(name="X", config_path=str(tmp_path / "not-a-dir" / "mcp.json"), key_path=("mcpServers", "h"), leaf=_LEAF) + + assert wire_mcp(c, _subs("https://u/mcp", "hk-1"))[0] is Status.FAILED def test_cli_install_removes_then_adds_at_user_scope(monkeypatch) -> None: @@ -61,7 +88,7 @@ def fake_run(cmd, **_): monkeypatch.setattr(mcp_hosts.subprocess, "run", fake_run) - status, _ = wire_mcp(mcp_hosts.CLIENTS["claude-code"], "https://u/mcp", "hk-rotated") + status, _ = wire_mcp(mcp_hosts.CLIENTS["claude-code"], _subs("https://u/mcp", "hk-rotated"), secret="hk-rotated") assert status is Status.INSTALLED *removes, add = calls @@ -116,12 +143,13 @@ def test_wire_skill_symlinks_bundled_skill_and_is_idempotent(monkeypatch, tmp_pa (tmp_path / ".cursor").mkdir() c = Client(name="X", skills_dir=".cursor/skills") - assert wire_skill(c)[0] is Status.INSTALLED + assert wire_skill(c, "hai-agents", mcp_hosts.bundled_skill())[0] is Status.INSTALLED link = tmp_path / ".cursor" / "skills" / "hai-agents" assert link.is_symlink() and (link / "SKILL.md").exists() - assert wire_skill(c)[0] is Status.SKIPPED + assert wire_skill(c, "hai-agents", mcp_hosts.bundled_skill())[0] is Status.SKIPPED def test_wire_skill_absent_when_client_dir_missing(monkeypatch, tmp_path) -> None: monkeypatch.setenv("HOME", str(tmp_path)) - assert wire_skill(Client(name="X", skills_dir=".cursor/skills"))[0] is Status.ABSENT + c = Client(name="X", skills_dir=".cursor/skills") + assert wire_skill(c, "hai-agents", mcp_hosts.bundled_skill())[0] is Status.ABSENT diff --git a/tests/test_runtime_placement.py b/tests/test_runtime_placement.py index 5344f5e..f260692 100644 --- a/tests/test_runtime_placement.py +++ b/tests/test_runtime_placement.py @@ -12,7 +12,9 @@ from hai_agents import AsyncClient, Client from hai_agents_local.runtime import BinaryIncompatibleError, Inference, LocalRuntime, LocalRuntimeError -from hai_agents_local.runtime.state import token_file_path, write_owner_only +from hai_agents_local.runtime.inference import served_inference +from hai_agents_local.runtime.runtime import default_port +from hai_agents_local.runtime.state import DEFAULT_PORT, inference_file_path, token_file_path, write_owner_only class RuntimeServer(ThreadingHTTPServer): @@ -181,6 +183,19 @@ def start(**options): assert launches[0]["spawn_env"].get("HAI_API_KEY") == forwarded +def test_runtimes_for_different_inference_never_share(tmp_path, runtime_server): + self_hosted_env = Inference.self_hosted("http://127.0.0.1:8000/v1", model="my-model").runtime_env() + hosted, self_hosted = served_inference({}), served_inference(self_hosted_env) + assert default_port(hosted) == DEFAULT_PORT != default_port(self_hosted) + + write_owner_only(token_file_path(runtime_server.port, cache_dir=tmp_path), "local-token") + write_owner_only(inference_file_path(runtime_server.port, cache_dir=tmp_path), f"{hosted}\n123") + start = {"cache_dir": tmp_path, "port": runtime_server.port, "inherit_env": False} + with pytest.raises(LocalRuntimeError, match="infers against hosted"): + LocalRuntime.ensure_started(spawn_env=self_hosted_env, **start) + assert LocalRuntime.ensure_started(spawn_env={}, **start).serves == hosted + + @pytest.mark.parametrize("proof_token", ["local-token", "squatter-token", None]) def test_only_the_runtime_holding_the_token_ever_receives_it(tmp_path, runtime_server, proof_token): write_owner_only(token_file_path(runtime_server.port, cache_dir=tmp_path), "local-token") @@ -416,6 +431,7 @@ def test_idle_shutdown_stops_an_owned_runtime_that_cannot_answer(tmp_path): port = probe.getsockname()[1] proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], start_new_session=True) token_file = write_owner_only(token_file_path(port, cache_dir=tmp_path), "token") + inference_file = write_owner_only(inference_file_path(port, cache_dir=tmp_path), f"hosted\n{proc.pid}") runtime = LocalRuntime( base_url=f"http://127.0.0.1:{port}", api_key="token", @@ -427,10 +443,12 @@ def test_idle_shutdown_stops_an_owned_runtime_that_cannot_answer(tmp_path): port=port, proc=proc, token_file=token_file, + serves="hosted", + inference_file=inference_file, ) try: assert runtime.shutdown_if_idle() - assert proc.poll() is not None and not token_file.exists() + assert proc.poll() is not None and not token_file.exists() and not inference_file.exists() finally: proc.kill() proc.wait() diff --git a/uv.lock b/uv.lock index 8dec80c..c626cf9 100644 --- a/uv.lock +++ b/uv.lock @@ -541,7 +541,7 @@ wheels = [ [[package]] name = "hai-agents" -version = "1.2.1" +version = "1.2.2" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -552,7 +552,9 @@ dependencies = [ [package.optional-dependencies] all = [ { name = "hai-drivers", extra = ["browser-cli", "cua-cli", "desktop", "desktop-cli", "web"] }, + { name = "pynput", marker = "sys_platform == 'win32'" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "rich" }, { name = "typer" }, ] @@ -561,6 +563,7 @@ browser = [ ] cli = [ { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "rich" }, { name = "typer" }, ] @@ -569,6 +572,7 @@ desktop = [ ] workstation = [ { name = "hai-drivers", extra = ["browser-cli", "cua-cli", "desktop-cli"] }, + { name = "pynput", marker = "sys_platform == 'win32'" }, ] [package.dev-dependencies] @@ -589,7 +593,9 @@ requires-dist = [ { name = "hai-drivers", extras = ["web"], marker = "extra == 'browser'", specifier = ">=0.1.3" }, { name = "httpx", specifier = ">=0.23.0,<0.29.0" }, { name = "pydantic", specifier = ">=2.0,<3" }, + { name = "pynput", marker = "sys_platform == 'win32' and extra == 'workstation'", specifier = ">=1.7.6" }, { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.2.3,<2" }, + { name = "pyyaml", marker = "extra == 'cli'", specifier = ">=6.0,<7" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=15.0.0,<16" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.27.2,<1" }, { name = "typing-extensions", specifier = ">=4.6" }, @@ -1622,6 +1628,70 @@ version = "1.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/79/0c/c16bc93ac2755bac0066a8ecbd2a2931a1735a6fffd99a2b9681c7e83e90/pytweening-1.2.0.tar.gz", hash = "sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b", size = 171241, upload-time = "2024-02-20T03:37:56.809Z" } +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "rich" version = "15.0.0"