feat(shared): long-lived Pi RPC client extracted from the memo-inbox gateway

Generalises PiRPC from pi-workspaces/memo-inbox/telegram-gateway/gateway.py,
which has run this pattern in production since 2026-07, and closes the four gaps
both existing scenarios shared:

- explicit minimal env, so provider and backend API keys never reach the node
  process (verified: 6 variables, an injected secret is withheld)
- start_new_session plus killpg on stop, so a stuck node tree cannot outlive the
  turn (verified: no orphan after stop)
- the loading-isolation flags are part of the launch contract instead of
  something each caller has to remember
- a per-turn deadline enforced with RPC abort rather than by killing the process

Retains the original's proven mechanics: strict newline-only JSONL framing,
correlation by id, agent_settled as terminal event, and receipts harvested from
tool_execution_end rather than from model prose.

PiLaunchConfig warns when skills are configured but no 'read' tool can be
active, which is exactly the condition that silently disabled Curator's SKILL.md.

Includes a zero-token smoke test: it drives a real pi process with get_state
only, so no model call is billed.
This commit is contained in:
Kai
2026-08-26 22:55:21 -07:00
parent 98635022d0
commit 65d2f5988b
2 changed files with 749 additions and 0 deletions
+659
View File
@@ -0,0 +1,659 @@
"""Long-lived Pi RPC client for gateway services.
Extracted and generalised from the memo-inbox gateway
(``pi-workspaces/memo-inbox/telegram-gateway/gateway.py``, class ``PiRPC``),
which has run this pattern in production since 2026-07.
Compared with the original it adds the four gaps that both existing scenarios
shared:
* an explicit minimal ``env`` so provider and backend API keys never reach the
node process (``docs/gateway-patterns.md`` pattern 8);
* ``start_new_session`` plus process-group termination so a timeout cannot leave
orphaned node children burning provider quota (pattern 7);
* the loading-isolation flags from ``docs/isolation-baseline.md`` are part of the
launch contract rather than something each caller remembers;
* a per-turn deadline enforced with RPC ``abort`` instead of killing the process.
It deliberately keeps the original's proven mechanics: strict ``\\n``-only JSONL
framing, request/response correlation by ``id``, ``agent_settled`` as the
terminal event, and deterministic receipts harvested from
``tool_execution_end`` rather than from model prose (pattern 3).
Stdlib only. Requires Python 3.11+ (``asyncio.timeout``).
Protocol reference: ``docs/pi-runtime-notes.md`` §15.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import signal
import time
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Awaitable, Callable, Iterable, Sequence
LOG = logging.getLogger("pi_rpc")
__all__ = [
"PiLaunchConfig",
"PiRpcClient",
"PiRpcError",
"PiRpcRejected",
"PiTurnAborted",
"TurnResult",
"Usage",
]
# Environment variables a pi subprocess legitimately needs. Everything else --
# in particular every ``*_API_KEY`` and ``*_TOKEN`` belonging to the host
# service -- is withheld. The provider credential is read by pi itself from
# ~/.pi/agent/models.json and must not be passed through the environment.
DEFAULT_ENV_ALLOWLIST: tuple[str, ...] = (
"PATH",
"HOME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TZ",
"TERM",
"NODE_OPTIONS",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NO_PROXY",
"no_proxy",
)
class PiRpcError(RuntimeError):
"""Transport or lifecycle failure."""
class PiRpcRejected(PiRpcError):
"""Pi returned ``success: false`` for a command."""
class PiTurnAborted(PiRpcError):
"""The turn exceeded its deadline and was aborted."""
@dataclass(frozen=True)
class Usage:
"""Token and cost accounting for one turn.
Populated from ``message_update.usage`` deltas. See
``docs/pi-runtime-notes.md`` §16 -- observability needs no extra
instrumentation because the stream already carries this.
"""
input: int = 0
output: int = 0
cache_read: int = 0
cache_write: int = 0
total_tokens: int = 0
cost_total: float = 0.0
def merged(self, raw: dict[str, Any]) -> "Usage":
cost = raw.get("cost") or {}
return Usage(
input=self.input + int(raw.get("input") or 0),
output=self.output + int(raw.get("output") or 0),
cache_read=self.cache_read + int(raw.get("cacheRead") or 0),
cache_write=self.cache_write + int(raw.get("cacheWrite") or 0),
total_tokens=self.total_tokens + int(raw.get("totalTokens") or 0),
cost_total=self.cost_total + float(cost.get("total") or 0.0),
)
@dataclass
class ToolCallRecord:
"""One completed tool execution."""
tool_name: str
args: dict[str, Any]
text: str
details: Any
is_error: bool
@dataclass
class TurnResult:
"""Everything one ``prompt`` produced.
``receipts`` holds the text of tool results whose tool is named in
``PiLaunchConfig.receipt_tools``. Callers must render user-visible outcomes
of state changes from ``receipts`` (or from ``tool_calls``), never from
``replies`` -- see ``docs/gateway-patterns.md`` pattern 3 for the production
failure this prevents.
"""
replies: list[str] = field(default_factory=list)
receipts: list[str] = field(default_factory=list)
tool_calls: list[ToolCallRecord] = field(default_factory=list)
usage: Usage = field(default_factory=Usage)
latency_seconds: float = 0.0
model: str = ""
thinking: str = ""
aborted: bool = False
extension_errors: list[dict[str, Any]] = field(default_factory=list)
@property
def text(self) -> str:
"""The final assistant message, or an empty string."""
return self.replies[-1] if self.replies else ""
@dataclass(frozen=True)
class PiLaunchConfig:
"""Launch contract for a dedicated Pi agent.
The isolation defaults implement ``docs/isolation-baseline.md``. Overriding
them widens what the agent loads or can call, so each override should be
justified in the scenario's ``profile.toml``.
"""
pi_bin: str
workspace: Path
session_dir: Path
provider: str
model: str
thinking: str = "medium"
display_name: str = "Pi Agent"
# --- session identity -------------------------------------------------
# A stable prefix; the client appends a rotation counter so that history
# stays auditable instead of being summarised away. Empty means --no-session.
session_id_prefix: str = ""
# --- layer 1: loading isolation --------------------------------------
extensions: tuple[Path, ...] = ()
skills: tuple[Path, ...] = ()
no_extensions: bool = True
no_skills: bool = True
no_prompt_templates: bool = True
no_themes: bool = True
no_context_files: bool = False
approve: bool = True
# --- layer 3: capability ---------------------------------------------
# Prefer no_builtin_tools over an explicit ``tools`` allowlist: --tools is a
# registry-level filter, which prevents an extension from registering tools
# at runtime. See docs/pi-runtime-notes.md section 4.
no_builtin_tools: bool = True
tools: tuple[str, ...] = ()
exclude_tools: tuple[str, ...] = ()
# --- budgets ----------------------------------------------------------
turn_deadline_seconds: float = 180.0
startup_timeout_seconds: float = 60.0
stop_timeout_seconds: float = 10.0
# --- rotation ---------------------------------------------------------
rotate_after_prompts: int = 24
rotate_after_messages: int = 60
# --- receipts ---------------------------------------------------------
receipt_tools: frozenset[str] = frozenset()
# --- environment ------------------------------------------------------
env_allowlist: tuple[str, ...] = DEFAULT_ENV_ALLOWLIST
extra_env: tuple[tuple[str, str], ...] = ()
def __post_init__(self) -> None:
if not self.no_builtin_tools and not self.tools:
LOG.warning(
"PiLaunchConfig for %r enables built-in tools with no allowlist; "
"bash/edit/write will be active",
self.display_name,
)
if self.skills and not self._read_reachable():
LOG.warning(
"PiLaunchConfig for %r loads skills but no 'read' tool is reachable; "
"the skills section will be omitted from the system prompt and the "
"skill bodies will be unloadable "
"(see docs/pi-runtime-notes.md section 1)",
self.display_name,
)
def _read_reachable(self) -> bool:
"""Whether an active tool named ``read`` can plausibly exist.
Pi only emits the skills section when ``read`` is active. With
``no_builtin_tools`` the extension is expected to register a restricted
``read`` override; with an explicit allowlist ``read`` must be named.
"""
if self.tools:
return "read" in self.tools
return self.no_builtin_tools or not self.no_extensions or bool(self.extensions)
def build_env(self) -> dict[str, str]:
env = {k: os.environ[k] for k in self.env_allowlist if k in os.environ}
env.setdefault("HOME", str(Path.home()))
env.update(dict(self.extra_env))
return env
def build_args(self, session_id: str | None) -> list[str]:
args: list[str] = [self.pi_bin, "--mode", "rpc"]
if session_id:
args += ["--session-id", session_id, "--session-dir", str(self.session_dir)]
else:
args += ["--no-session"]
if self.no_builtin_tools:
args += ["--no-builtin-tools"]
if self.tools:
args += ["--tools", ",".join(self.tools)]
if self.exclude_tools:
args += ["--exclude-tools", ",".join(self.exclude_tools)]
if self.no_extensions:
args += ["--no-extensions"]
for path in self.extensions:
args += ["-e", str(path)]
if self.no_skills:
args += ["--no-skills"]
for path in self.skills:
args += ["--skill", str(path)]
if self.no_prompt_templates:
args += ["--no-prompt-templates"]
if self.no_themes:
args += ["--no-themes"]
if self.no_context_files:
args += ["--no-context-files"]
if self.approve:
args += ["--approve"]
args += [
"--provider", self.provider,
"--model", self.model,
"--thinking", self.thinking,
"--name", self.display_name,
]
return args
class PiRpcClient:
"""A single long-lived ``pi --mode rpc`` process.
One client owns one conversation. Instantiate one per Telegram chat, per
ticket, or per whatever your unit of continuity is; do not multiplex
unrelated conversations through one client, because they would share a
session and contaminate each other.
Not safe for concurrent ``prompt`` calls -- an internal lock serialises
commands, so callers queue rather than interleave.
"""
def __init__(
self,
config: PiLaunchConfig,
*,
on_event: Callable[[dict[str, Any]], Awaitable[None] | None] | None = None,
) -> None:
self.config = config
self._on_event = on_event
self._process: asyncio.subprocess.Process | None = None
self._events: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
self._lock = asyncio.Lock()
self._stdout_task: asyncio.Task[None] | None = None
self._stderr_task: asyncio.Task[None] | None = None
self._stderr_tail: list[str] = []
self._prompt_count = 0
self._rotation = 0
# -- lifecycle ---------------------------------------------------------
@property
def running(self) -> bool:
return self._process is not None and self._process.returncode is None
@property
def pid(self) -> int | None:
return self._process.pid if self._process else None
def _session_id(self) -> str | None:
if not self.config.session_id_prefix:
return None
return f"{self.config.session_id_prefix}-{self._rotation:04d}"
async def start(self) -> None:
if self.running:
return
cfg = self.config
cfg.session_dir.mkdir(parents=True, exist_ok=True)
args = cfg.build_args(self._session_id())
LOG.info("Starting pi: %s", " ".join(args))
self._process = await asyncio.create_subprocess_exec(
*args,
cwd=str(cfg.workspace),
env=cfg.build_env(),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
# Own process group so a stuck node tree can be killed wholesale.
start_new_session=True,
)
self._stdout_task = asyncio.create_task(self._read_stdout(), name="pi-rpc-stdout")
self._stderr_task = asyncio.create_task(self._read_stderr(), name="pi-rpc-stderr")
try:
async with asyncio.timeout(cfg.startup_timeout_seconds):
await self._command({"type": "get_state"}, wait_for_settled=False, use_lock=False)
except TimeoutError as exc:
tail = " | ".join(self._stderr_tail[-5:])
await self.stop()
raise PiRpcError(f"pi did not answer get_state within "
f"{cfg.startup_timeout_seconds}s: {tail}") from exc
LOG.info("pi RPC ready pid=%s session=%s", self.pid, self._session_id())
async def stop(self) -> None:
process, self._process = self._process, None
for task in (self._stdout_task, self._stderr_task):
if task:
task.cancel()
for task in (self._stdout_task, self._stderr_task):
if task:
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
self._stdout_task = self._stderr_task = None
if process is None or process.returncode is not None:
return
# Terminate the whole group: pi is a node CLI and may have children.
with contextlib.suppress(ProcessLookupError, PermissionError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
try:
async with asyncio.timeout(self.config.stop_timeout_seconds):
await process.wait()
except TimeoutError:
LOG.warning("pi pid=%s ignored SIGTERM, sending SIGKILL to group", process.pid)
with contextlib.suppress(ProcessLookupError, PermissionError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
with contextlib.suppress(Exception):
await process.wait()
async def __aenter__(self) -> "PiRpcClient":
await self.start()
return self
async def __aexit__(self, *_exc: object) -> None:
await self.stop()
# -- stream plumbing ---------------------------------------------------
async def _read_stdout(self) -> None:
assert self._process and self._process.stdout
buffer = b""
# Strict JSONL: split on \n only. A generic line reader is not
# protocol-compliant because U+2028/U+2029 are legal inside JSON
# strings. See docs/pi-runtime-notes.md section 15.
while chunk := await self._process.stdout.read(65536):
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
line = line.rstrip(b"\r")
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
LOG.warning("Discarding malformed pi RPC record (%d bytes)", len(line))
continue
await self._events.put(event)
if self._on_event is not None:
try:
result = self._on_event(event)
if asyncio.iscoroutine(result):
await result
except Exception:
LOG.exception("on_event callback failed")
LOG.warning("pi RPC stdout closed")
async def _read_stderr(self) -> None:
assert self._process and self._process.stderr
while line := await self._process.stderr.readline():
text = line.decode(errors="replace").rstrip()
if not text:
continue
# Kept even on success: the memo/curator scenarios both discarded
# stderr and then could not explain model failures afterwards.
self._stderr_tail.append(text)
del self._stderr_tail[:-50]
LOG.info("pi stderr: %s", text)
async def _send(self, payload: dict[str, Any]) -> None:
await self.start()
assert self._process and self._process.stdin
blob = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
self._process.stdin.write(blob + b"\n")
await self._process.stdin.drain()
# -- commands ----------------------------------------------------------
async def command(self, payload: dict[str, Any], *, wait_for_settled: bool = False,
deadline: float | None = None) -> tuple[dict[str, Any], TurnResult]:
async with self._lock:
return await self._command(payload, wait_for_settled=wait_for_settled,
use_lock=False, deadline=deadline)
async def _command(
self,
payload: dict[str, Any],
*,
wait_for_settled: bool,
use_lock: bool = True,
deadline: float | None = None,
) -> tuple[dict[str, Any], TurnResult]:
if use_lock:
async with self._lock:
return await self._command(payload, wait_for_settled=wait_for_settled,
use_lock=False, deadline=deadline)
request_id = payload.setdefault("id", f"req-{time.monotonic_ns():x}")
# Drop anything left over from an aborted turn so correlation is clean.
while not self._events.empty():
self._events.get_nowait()
started = time.monotonic()
result = TurnResult(model=self.config.model, thinking=self.config.thinking)
await self._send(payload)
budget = deadline if deadline is not None else self.config.turn_deadline_seconds
response: dict[str, Any] | None = None
try:
async with asyncio.timeout(budget):
response = await self._consume(request_id, result, wait_for_settled)
except TimeoutError:
result.aborted = True
result.latency_seconds = time.monotonic() - started
LOG.warning("pi turn exceeded %.0fs; aborting", budget)
with contextlib.suppress(Exception):
await self._send({"id": f"{request_id}-abort", "type": "abort"})
raise PiTurnAborted(f"pi turn exceeded {budget:.0f}s") from None
result.latency_seconds = time.monotonic() - started
assert response is not None
return response, result
async def _consume(self, request_id: str, result: TurnResult,
wait_for_settled: bool) -> dict[str, Any]:
"""Collect events until the command is complete.
``success: true`` only means *accepted*; post-acceptance failures arrive
as events. So for a prompt we wait for ``agent_settled`` as well.
"""
response: dict[str, Any] | None = None
pending_args: dict[str, dict[str, Any]] = {}
while True:
event = await self._events.get()
kind = event.get("type")
if kind == "response" and event.get("id") == request_id:
response = event
if not wait_for_settled or not event.get("success"):
return response
elif kind == "message_update":
raw = event.get("usage")
if isinstance(raw, dict):
result.usage = result.usage.merged(raw)
elif kind == "message_end":
text = _assistant_text(event.get("message") or {})
if text:
result.replies.append(text)
elif kind == "tool_execution_start":
call_id = str(event.get("toolCallId") or "")
if call_id:
pending_args[call_id] = event.get("args") or {}
elif kind == "tool_execution_end":
name = str(event.get("toolName") or "")
payload = event.get("result") or {}
text = _result_text(payload)
record = ToolCallRecord(
tool_name=name,
args=pending_args.pop(str(event.get("toolCallId") or ""), {}),
text=text,
details=payload.get("details"),
is_error=bool(event.get("isError")),
)
result.tool_calls.append(record)
# Deterministic receipts, not model prose. Pattern 3.
if name in self.config.receipt_tools and text and not record.is_error:
result.receipts.append(text)
elif kind == "extension_error":
LOG.error("pi extension error: %s", event)
result.extension_errors.append(event)
elif kind == "agent_settled" and response is not None:
return response
# -- high level --------------------------------------------------------
async def prompt(self, message: str, *, deadline: float | None = None,
images: Sequence[dict[str, Any]] | None = None) -> TurnResult:
"""Send one user turn and return everything it produced.
Rotates the session first when either rotation bound is reached: pi has
no session TTL and auto-compaction does not fire on large-context models
(``docs/pi-runtime-notes.md`` sections 17-18).
"""
await self._maybe_rotate()
payload: dict[str, Any] = {"type": "prompt", "message": message}
if images:
payload["images"] = list(images)
response, result = await self.command(payload, wait_for_settled=True, deadline=deadline)
if not response.get("success"):
raise PiRpcRejected(str(response.get("error") or "pi rejected the prompt"))
self._prompt_count += 1
return result
async def _maybe_rotate(self) -> None:
cfg = self.config
if not cfg.session_id_prefix:
return
if cfg.rotate_after_prompts and self._prompt_count >= cfg.rotate_after_prompts:
LOG.info("Rotating pi session: prompt_count=%d", self._prompt_count)
await self.rotate_session()
return
if cfg.rotate_after_messages:
state = await self.get_state()
count = int(state.get("messageCount") or 0)
if count >= cfg.rotate_after_messages:
LOG.info("Rotating pi session: message_count=%d", count)
await self.rotate_session()
async def rotate_session(self) -> None:
"""Start a fresh session, preserving the previous file for audit.
Restarting the process with the next ``--session-id`` is preferred over
the ``new_session`` command because the session id then encodes the
rotation, so history remains greppable on disk.
"""
self._rotation += 1
self._prompt_count = 0
await self.stop()
await self.start()
async def get_state(self) -> dict[str, Any]:
response, _ = await self.command({"type": "get_state"})
return response.get("data") or {}
async def get_session_stats(self) -> dict[str, Any]:
response, _ = await self.command({"type": "get_session_stats"})
return response.get("data") or {}
async def set_model(self, model: str) -> None:
response, _ = await self.command({"type": "set_model", "model": model})
if not response.get("success"):
raise PiRpcRejected(str(response.get("error") or f"cannot select model {model}"))
self.config = replace(self.config, model=model)
async def set_thinking_level(self, level: str) -> None:
response, _ = await self.command({"type": "set_thinking_level", "level": level})
if not response.get("success"):
raise PiRpcRejected(str(response.get("error") or f"cannot set thinking {level}"))
self.config = replace(self.config, thinking=level)
async def abort(self) -> None:
await self.command({"type": "abort"})
async def compact(self, instructions: str | None = None) -> None:
payload: dict[str, Any] = {"type": "compact"}
if instructions:
payload["customInstructions"] = instructions
await self.command(payload, wait_for_settled=False)
@property
def stderr_tail(self) -> list[str]:
"""Recent stderr lines, retained even on success for diagnostics."""
return list(self._stderr_tail)
# -- helpers ---------------------------------------------------------------
def _assistant_text(message: dict[str, Any]) -> str:
"""Concatenate the text blocks of an assistant message.
Thinking blocks are excluded: they are reasoning, not output.
"""
content = message.get("content")
if isinstance(content, str):
return content.strip()
if not isinstance(content, Iterable):
return ""
parts = [
block["text"]
for block in content
if isinstance(block, dict)
and block.get("type") == "text"
and isinstance(block.get("text"), str)
]
return "\n".join(parts).strip()
def _result_text(result: dict[str, Any]) -> str:
content = result.get("content") or []
if not isinstance(content, Iterable):
return ""
parts = [
block["text"]
for block in content
if isinstance(block, dict)
and block.get("type") == "text"
and isinstance(block.get("text"), str)
]
return "\n".join(parts).strip()
+90
View File
@@ -0,0 +1,90 @@
"""Zero-token smoke test for pi_rpc against a real pi process.
Sends only `get_state`, so no model call is made. Verifies that the launch
contract produces a live process, that the isolation flags land, and that the
process group is cleaned up.
Run: python3 shared/lib/py/tests/test_pi_rpc_smoke.py
"""
from __future__ import annotations
import asyncio
import os
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from pi_rpc import PiLaunchConfig, PiRpcClient # noqa: E402
REPO = Path(__file__).resolve().parents[4]
HARNESS = REPO / "docs" / "evidence" / "probe-harness"
PI_BIN = os.environ.get("PI_BIN", "/home/claw/.npm-global/bin/pi")
def check(label: str, ok: bool, detail: str = "") -> bool:
print(f" {'PASS' if ok else 'FAIL'} {label}{('' + detail) if detail else ''}")
return ok
async def main() -> int:
failures = 0
print("== build_args reflects the isolation baseline ==")
cfg = PiLaunchConfig(
pi_bin=PI_BIN,
workspace=HARNESS,
session_dir=Path("/tmp/pi-rpc-smoke-sessions"),
provider="zenmux",
model="openai/gpt-5.6-luna",
thinking="low",
display_name="pi_rpc smoke",
extensions=(HARNESS / "probe-ext.ts",),
skills=(HARNESS / ".pi" / "skills" / "probe-skill",),
session_id_prefix="", # ephemeral for the smoke test
receipt_tools=frozenset({"probe_plain_schema"}),
turn_deadline_seconds=45.0,
)
args = cfg.build_args(None)
for flag in ("--no-builtin-tools", "--no-extensions", "--no-skills",
"--no-prompt-templates", "--no-themes", "--approve", "--no-session"):
failures += not check(f"{flag} present", flag in args)
failures += not check("--tools absent (registry allowlist would block dynamic tools)",
"--tools" not in args)
print("== env is minimal ==")
os.environ["SMOKE_FAKE_SECRET"] = "must-not-propagate"
env = cfg.build_env()
failures += not check("secret withheld", "SMOKE_FAKE_SECRET" not in env)
failures += not check("PATH forwarded", "PATH" in env)
failures += not check(f"small env ({len(env)} vars)", len(env) < 20)
print("== live process: start, get_state, stop ==")
client = PiRpcClient(cfg)
try:
await client.start()
pid = client.pid
failures += not check("process running", client.running, f"pid={pid}")
state = await client.get_state()
failures += not check("get_state returned a model", bool(state.get("model")),
str(state.get("model", {}).get("id", "?")))
failures += not check("not streaming", state.get("isStreaming") is False)
failures += not check("thinkingLevel honoured", state.get("thinkingLevel") == "low",
str(state.get("thinkingLevel")))
stats = await client.get_session_stats()
failures += not check("get_session_stats works", "tokens" in stats)
finally:
await client.stop()
failures += not check("process stopped", not client.running)
if pid:
alive = subprocess.run(["ps", "-p", str(pid)], capture_output=True).returncode == 0
failures += not check("no orphan process", not alive)
print()
print("RESULT:", "all checks passed" if failures == 0 else f"{failures} check(s) failed")
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))