hiair-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: astral-sh/setup-uv@v6
16
+
17
+ - name: Build sdist and wheel
18
+ run: uv build
19
+
20
+ - uses: actions/upload-artifact@v4
21
+ with:
22
+ name: dist
23
+ path: dist/
24
+
25
+ publish:
26
+ needs: build
27
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
28
+ runs-on: ubuntu-latest
29
+ environment:
30
+ name: pypi
31
+ url: https://pypi.org/p/hiair-cli
32
+ permissions:
33
+ id-token: write
34
+ contents: write
35
+ steps:
36
+ - uses: actions/download-artifact@v4
37
+ with:
38
+ name: dist
39
+ path: dist
40
+
41
+ - uses: pypa/gh-action-pypi-publish@release/v1
42
+
43
+ - name: GitHub Release
44
+ env:
45
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46
+ run: |
47
+ gh release create "${{ github.ref_name }}" dist/* \
48
+ --title "${{ github.ref_name }}" \
49
+ --generate-notes \
50
+ --verify-tag
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HiAir
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.5
2
+ Name: hiair-cli
3
+ Version: 0.1.0
4
+ Summary: Run HiAir applies in your own Chrome
5
+ Project-URL: Homepage, https://hiair.ai
6
+ Project-URL: Repository, https://github.com/hiair-ai/hiair-cli
7
+ Project-URL: Issues, https://github.com/hiair-ai/hiair-cli/issues
8
+ Author-email: HiAir <admin@hiair.ai>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: apply,chrome,hiair,jobs
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Office/Business
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: httpx>=0.27.0
25
+ Requires-Dist: mcp>=2.0.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # HiAir CLI
29
+
30
+ Fill job applications in **your** Chrome, then store them in the HiAir tracker.
31
+
32
+ ## Install
33
+
34
+ You need [Python 3.11+](https://www.python.org/downloads/) and [pipx](https://pipx.pypa.io/). Google Chrome must be installed for applies.
35
+
36
+ ```bash
37
+ pipx install hiair-cli
38
+ # or: uv tool install hiair-cli
39
+
40
+ hiair login
41
+ hiair service install
42
+ ```
43
+
44
+ After that, the worker starts at login. Queue jobs from the website or copilot; when one arrives, this Mac opens a disposable Chrome, fills the application, and writes the result back. Everyday Chrome can stay open. Logins in the HiAir window do not persist.
45
+
46
+ `hiair service` is macOS only. To watch a run in the foreground (any OS), use `hiair apply`.
47
+
48
+ ```bash
49
+ hiair --version
50
+ hiair status
51
+ hiair service status
52
+ hiair service uninstall
53
+ ```
@@ -0,0 +1,26 @@
1
+ # HiAir CLI
2
+
3
+ Fill job applications in **your** Chrome, then store them in the HiAir tracker.
4
+
5
+ ## Install
6
+
7
+ You need [Python 3.11+](https://www.python.org/downloads/) and [pipx](https://pipx.pypa.io/). Google Chrome must be installed for applies.
8
+
9
+ ```bash
10
+ pipx install hiair-cli
11
+ # or: uv tool install hiair-cli
12
+
13
+ hiair login
14
+ hiair service install
15
+ ```
16
+
17
+ After that, the worker starts at login. Queue jobs from the website or copilot; when one arrives, this Mac opens a disposable Chrome, fills the application, and writes the result back. Everyday Chrome can stay open. Logins in the HiAir window do not persist.
18
+
19
+ `hiair service` is macOS only. To watch a run in the foreground (any OS), use `hiair apply`.
20
+
21
+ ```bash
22
+ hiair --version
23
+ hiair status
24
+ hiair service status
25
+ hiair service uninstall
26
+ ```
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "hiair-cli"
3
+ dynamic = ["version"]
4
+ description = "Run HiAir applies in your own Chrome"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "HiAir", email = "admin@hiair.ai" }]
9
+ keywords = ["hiair", "jobs", "apply", "chrome"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: End Users/Desktop",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: MacOS",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Office/Business",
22
+ ]
23
+ dependencies = [
24
+ "httpx>=0.27.0",
25
+ "mcp>=2.0.0",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://hiair.ai"
30
+ Repository = "https://github.com/hiair-ai/hiair-cli"
31
+ Issues = "https://github.com/hiair-ai/hiair-cli/issues"
32
+
33
+ [project.scripts]
34
+ hiair = "hiair_cli.main:main"
35
+
36
+ [build-system]
37
+ requires = ["hatchling"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.version]
41
+ path = "src/hiair_cli/__init__.py"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/hiair_cli"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from hiair_cli.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ CONFIG_DIR = Path.home() / ".hiair"
9
+ CREDENTIALS_PATH = CONFIG_DIR / "credentials"
10
+
11
+
12
+ def default_api_url() -> str:
13
+ return (os.environ.get("HIAIR_API_URL") or "https://api.hiair.ai").rstrip("/")
14
+
15
+
16
+ def load_credentials() -> dict[str, Any]:
17
+ if not CREDENTIALS_PATH.is_file():
18
+ return {}
19
+ try:
20
+ data = json.loads(CREDENTIALS_PATH.read_text(encoding="utf-8"))
21
+ except Exception:
22
+ return {}
23
+ return data if isinstance(data, dict) else {}
24
+
25
+
26
+ def save_credentials(*, token: str, api_url: str, device_id: str, device_name: str) -> None:
27
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
28
+ payload = {
29
+ "token": token,
30
+ "api_url": api_url.rstrip("/"),
31
+ "device_id": device_id,
32
+ "device_name": device_name,
33
+ }
34
+ CREDENTIALS_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
35
+ try:
36
+ CREDENTIALS_PATH.chmod(0o600)
37
+ except OSError:
38
+ pass
39
+
40
+
41
+ def clear_credentials() -> None:
42
+ if CREDENTIALS_PATH.is_file():
43
+ CREDENTIALS_PATH.unlink()
44
+
45
+
46
+ def require_token() -> tuple[str, str]:
47
+ data = load_credentials()
48
+ token = str(data.get("token") or "").strip()
49
+ api_url = str(data.get("api_url") or default_api_url()).rstrip("/")
50
+ if not token:
51
+ raise SystemExit("Not logged in. Run `hiair login` first.")
52
+ return token, api_url
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import signal
7
+ import socket
8
+ import subprocess
9
+ import tempfile
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ DEFAULT_CDP_PORT = 9222
17
+ CDP_URL = f"http://127.0.0.1:{DEFAULT_CDP_PORT}"
18
+
19
+
20
+ @dataclass
21
+ class ChromeSession:
22
+ endpoint: str
23
+ process: subprocess.Popen[bytes]
24
+ profile_dir: str
25
+
26
+
27
+ def cdp_ready(url: str = f"{CDP_URL}/json/version") -> bool:
28
+ try:
29
+ with urllib.request.urlopen(url, timeout=1.5) as response:
30
+ return 200 <= response.status < 300
31
+ except (urllib.error.URLError, TimeoutError, OSError):
32
+ return False
33
+
34
+
35
+ def find_chrome() -> Path:
36
+ system = platform.system()
37
+ candidates: list[Path] = []
38
+ if system == "Darwin":
39
+ candidates.append(Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"))
40
+ elif system == "Windows":
41
+ for env in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
42
+ root = os.environ.get(env)
43
+ if root:
44
+ candidates.append(Path(root) / "Google/Chrome/Application/chrome.exe")
45
+ else:
46
+ for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
47
+ found = shutil.which(name)
48
+ if found:
49
+ return Path(found)
50
+ for path in candidates:
51
+ if path.is_file():
52
+ return path
53
+ raise SystemExit(
54
+ "Google Chrome was not found. Install Chrome and try `hiair apply` again."
55
+ )
56
+
57
+
58
+ def _port_open(port: int) -> bool:
59
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
60
+ sock.settimeout(0.3)
61
+ return sock.connect_ex(("127.0.0.1", port)) == 0
62
+
63
+
64
+ def _wait_for_cdp(endpoint: str, *, seconds: float = 20) -> bool:
65
+ deadline = time.monotonic() + seconds
66
+ while time.monotonic() < deadline:
67
+ if cdp_ready(f"{endpoint}/json/version"):
68
+ return True
69
+ time.sleep(0.25)
70
+ return False
71
+
72
+
73
+ def _pick_cdp_port(preferred: int) -> int:
74
+ if not _port_open(preferred):
75
+ return preferred
76
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
77
+ sock.bind(("127.0.0.1", 0))
78
+ return int(sock.getsockname()[1])
79
+
80
+
81
+ def ensure_chrome(*, cdp_port: int = DEFAULT_CDP_PORT) -> ChromeSession:
82
+ """Launch a fresh Chrome that exposes CDP.
83
+
84
+ Chrome 136+ ignores --remote-debugging-port on the default profile, so each
85
+ run gets a unique temp user-data-dir. Everyday Chrome can stay open.
86
+ """
87
+
88
+ port = _pick_cdp_port(cdp_port)
89
+ endpoint = f"http://127.0.0.1:{port}"
90
+ profile_dir = tempfile.mkdtemp(prefix="hiair-chrome-")
91
+ chrome = find_chrome()
92
+ args = [
93
+ str(chrome),
94
+ f"--remote-debugging-port={port}",
95
+ "--remote-allow-origins=*",
96
+ f"--user-data-dir={profile_dir}",
97
+ "--no-first-run",
98
+ "--no-default-browser-check",
99
+ ]
100
+ print("Opening a fresh Chrome. Your everyday Chrome can stay open.")
101
+ process = subprocess.Popen(
102
+ args,
103
+ stdout=subprocess.DEVNULL,
104
+ stderr=subprocess.DEVNULL,
105
+ start_new_session=True,
106
+ )
107
+ session = ChromeSession(endpoint=endpoint, process=process, profile_dir=profile_dir)
108
+ if _wait_for_cdp(endpoint):
109
+ print(f"Chrome is ready on {endpoint}")
110
+ return session
111
+ close_chrome(session)
112
+ raise RuntimeError(
113
+ "Chrome started but remote debugging did not come up. "
114
+ "Quit leftover HiAir Chrome windows and try again."
115
+ )
116
+
117
+
118
+ def close_chrome(session: ChromeSession) -> None:
119
+ proc = session.process
120
+ if proc.poll() is not None:
121
+ return
122
+ try:
123
+ os.killpg(proc.pid, signal.SIGTERM)
124
+ except (ProcessLookupError, PermissionError, OSError):
125
+ proc.terminate()
126
+ try:
127
+ proc.wait(timeout=5)
128
+ except subprocess.TimeoutExpired:
129
+ try:
130
+ os.killpg(proc.pid, signal.SIGKILL)
131
+ except (ProcessLookupError, PermissionError, OSError):
132
+ proc.kill()
133
+ try:
134
+ proc.wait(timeout=3)
135
+ except subprocess.TimeoutExpired:
136
+ pass
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from hiair_cli.auth import default_api_url
8
+
9
+
10
+ class CliApiError(RuntimeError):
11
+ def __init__(self, status_code: int, detail: str) -> None:
12
+ super().__init__(detail)
13
+ self.status_code = status_code
14
+ self.detail = detail
15
+
16
+
17
+ class CliClient:
18
+ def __init__(self, *, api_url: str | None = None, token: str | None = None) -> None:
19
+ self.api_url = (api_url or default_api_url()).rstrip("/")
20
+ self.token = token
21
+ self._http = httpx.Client(timeout=60.0)
22
+
23
+ def close(self) -> None:
24
+ self._http.close()
25
+
26
+ def _headers(self, *, auth: bool = False) -> dict[str, str]:
27
+ headers = {"Accept": "application/json"}
28
+ if auth:
29
+ if not self.token:
30
+ raise SystemExit("Not logged in. Run `hiair login` first.")
31
+ headers["Authorization"] = f"Bearer {self.token}"
32
+ return headers
33
+
34
+ def _request(
35
+ self,
36
+ method: str,
37
+ path: str,
38
+ *,
39
+ auth: bool = False,
40
+ json: Any = None,
41
+ params: dict[str, Any] | None = None,
42
+ timeout: float | None = None,
43
+ accept_empty: bool = False,
44
+ ) -> Any:
45
+ response = self._http.request(
46
+ method,
47
+ f"{self.api_url}{path}",
48
+ headers=self._headers(auth=auth),
49
+ json=json,
50
+ params=params,
51
+ timeout=timeout,
52
+ )
53
+ if response.status_code == 202:
54
+ return {"pending": True}
55
+ if accept_empty and response.status_code in {200, 204} and not response.content:
56
+ return None
57
+ if response.status_code >= 400:
58
+ detail = response.text
59
+ try:
60
+ payload = response.json()
61
+ raw = payload.get("detail")
62
+ if isinstance(raw, str):
63
+ detail = raw
64
+ elif isinstance(raw, dict):
65
+ detail = str(raw.get("message") or raw)
66
+ except Exception:
67
+ pass
68
+ raise CliApiError(response.status_code, detail)
69
+ if not response.content:
70
+ return None
71
+ return response.json()
72
+
73
+ def pair_start(self, name: str) -> dict[str, Any]:
74
+ return self._request("POST", "/cli/pair/start", json={"name": name})
75
+
76
+ def pair_token(self, device_code: str) -> dict[str, Any]:
77
+ return self._request("POST", "/cli/pair/token", json={"device_code": device_code})
78
+
79
+ def me(self) -> dict[str, Any]:
80
+ return self._request("GET", "/cli/me", auth=True)
81
+
82
+ def heartbeat(self) -> dict[str, Any]:
83
+ return self._request("POST", "/cli/heartbeat", auth=True)
84
+
85
+ def logout(self) -> None:
86
+ self._request("POST", "/cli/logout", auth=True, accept_empty=True)
87
+
88
+ def next_run(self, *, wait: float = 25) -> dict[str, Any] | None:
89
+ return self._request(
90
+ "GET",
91
+ "/cli/runs/next",
92
+ auth=True,
93
+ params={"wait": wait},
94
+ timeout=wait + 10,
95
+ accept_empty=True,
96
+ )
97
+
98
+ def turn(
99
+ self,
100
+ run_id: int,
101
+ *,
102
+ tools: list[dict[str, Any]] | None = None,
103
+ resume_path: str | None = None,
104
+ results: list[dict[str, Any]] | None = None,
105
+ ) -> dict[str, Any]:
106
+ return self._request(
107
+ "POST",
108
+ f"/cli/runs/{run_id}/turn",
109
+ auth=True,
110
+ json={
111
+ "tools": tools or [],
112
+ "resume_path": resume_path,
113
+ "results": results or [],
114
+ },
115
+ timeout=180,
116
+ )
117
+
118
+ def upload_artifacts(self, run_id: int, items: list[dict[str, Any]]) -> dict[str, Any]:
119
+ return self._request(
120
+ "POST",
121
+ f"/cli/runs/{run_id}/artifacts",
122
+ auth=True,
123
+ json=items,
124
+ )
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from hiair_cli.chrome import close_chrome, ensure_chrome
12
+ from hiair_cli.client import CliApiError, CliClient
13
+ from hiair_cli.mcp import LocalPlaywrightMcp
14
+
15
+ CAPTURE_AFTER = frozenset(
16
+ {
17
+ "browser_click",
18
+ "browser_click_element",
19
+ "browser_type",
20
+ "browser_press_key",
21
+ "browser_select_option",
22
+ "browser_fill_form",
23
+ "browser_navigate",
24
+ }
25
+ )
26
+ HEARTBEAT_SECONDS = 20
27
+
28
+
29
+ async def _download_resume(url: str, dest: Path) -> None:
30
+ async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
31
+ response = await client.get(url)
32
+ response.raise_for_status()
33
+ dest.write_bytes(response.content)
34
+
35
+
36
+ def _artifact(filename: str, data: bytes, kind: str) -> dict[str, Any]:
37
+ return {
38
+ "filename": filename,
39
+ "content_base64": base64.b64encode(data).decode("ascii"),
40
+ "kind": kind,
41
+ }
42
+
43
+
44
+ async def _run_one(client: CliClient, claim: dict[str, Any], mcp: LocalPlaywrightMcp) -> None:
45
+ run_id = int(claim["run_id"])
46
+ resume_name = str(claim.get("resume_file_name") or "resume.pdf")
47
+ resume_path = mcp.output_dir / resume_name
48
+ resume_url = (claim.get("resume_url") or "").strip()
49
+ if resume_url:
50
+ try:
51
+ await _download_resume(resume_url, resume_path)
52
+ except Exception as exc:
53
+ print(f" could not download resume: {exc}")
54
+ print(f"Applying to {claim.get('job_title') or claim.get('job_url')} (run {run_id})")
55
+ results: list[dict[str, Any]] = []
56
+ tools = mcp.tool_specs()
57
+ first = True
58
+ frames: list[dict[str, Any]] = []
59
+ seq = 0
60
+ while True:
61
+ turn = client.turn(
62
+ run_id,
63
+ tools=tools if first else None,
64
+ resume_path=str(resume_path) if first else None,
65
+ results=results,
66
+ )
67
+ first = False
68
+ if turn.get("finished"):
69
+ status = turn.get("status") or "unknown"
70
+ print(f" finished: {status}")
71
+ if turn.get("error"):
72
+ print(f" {turn['error']}")
73
+ try:
74
+ proof = await mcp.screenshot(full_page=True)
75
+ items = list(frames)
76
+ if proof:
77
+ items.append(_artifact("filled.png", proof, "proof"))
78
+ if items:
79
+ client.upload_artifacts(run_id, items)
80
+ except Exception as exc:
81
+ print(f" could not upload proof: {exc}")
82
+ return
83
+ results = []
84
+ for call in turn.get("tool_calls") or []:
85
+ name = str(call.get("name") or "")
86
+ args = call.get("args") or {}
87
+ print(f" → {name}")
88
+ output = await mcp.call(name, args)
89
+ results.append({"id": str(call.get("id") or ""), "output": output})
90
+ if name in CAPTURE_AFTER:
91
+ seq += 1
92
+ try:
93
+ png = await mcp.screenshot(full_page=True)
94
+ if png:
95
+ frames.append(_artifact(f"{seq:04d}-{name}.png", png, "screenshot"))
96
+ except Exception:
97
+ pass
98
+
99
+
100
+ async def run_daemon(client: CliClient) -> None:
101
+ heartbeat = asyncio.create_task(_heartbeat_loop(client))
102
+ try:
103
+ while True:
104
+ try:
105
+ claim = client.next_run(wait=25)
106
+ except CliApiError as exc:
107
+ print(f"API error: {exc.detail}")
108
+ await asyncio.sleep(5)
109
+ continue
110
+ if not claim:
111
+ continue
112
+ chrome = None
113
+ mcp = None
114
+ try:
115
+ chrome = ensure_chrome()
116
+ output_dir = Path(tempfile.mkdtemp(prefix=f"hiair-apply-{claim['run_id']}-"))
117
+ mcp = LocalPlaywrightMcp(output_dir=output_dir, cdp_endpoint=chrome.endpoint)
118
+ await mcp.start()
119
+ await _run_one(client, claim, mcp)
120
+ except CliApiError as exc:
121
+ print(f" apply failed: {exc.detail}")
122
+ except Exception as exc:
123
+ print(f" apply crashed: {exc}")
124
+ finally:
125
+ if mcp is not None:
126
+ await mcp.close()
127
+ if chrome is not None:
128
+ close_chrome(chrome)
129
+ finally:
130
+ heartbeat.cancel()
131
+ try:
132
+ await heartbeat
133
+ except (asyncio.CancelledError, Exception):
134
+ pass
135
+
136
+
137
+ async def _heartbeat_loop(client: CliClient) -> None:
138
+ while True:
139
+ try:
140
+ client.heartbeat()
141
+ except Exception:
142
+ pass
143
+ await asyncio.sleep(HEARTBEAT_SECONDS)