Files
EvoScientist-Multi/EvoScientist/channels/imessage/rpc_client.py
T
dinos 8b1451cdda refactor(runtime): centralize async bridges under an owned runtime (#376)
* feat(runtime): add application-scoped async runtime

* refactor(cli): use owned runtime for session stats

* refactor(onboard): use the owned async runtime

* docs(runtime): record async bridge ownership

* refactor(middleware): keep sync fallback synchronous

* refactor(mcp): load tools on an owned runtime

* refactor(cli): share owned runtime across entry points

* refactor(channels): make inbound sync bridge explicit

* refactor(stream): run Rich streaming on owned runtime

* chore(runtime): remove nest-asyncio dependency

* refactor(asyncio): require active loops in async code

* docs(runtime): document final event loop ownership

* fix(stream): cancel stalled owned streams

* fix(cli): recover cleanly from stream cancellation

* fix(runtime): drain executor work before shutdown

* fix(runtime): terminate cancelled shell process trees

* fix(models): let fallback bypass selector failures

* fix(cli): reset interrupt handling between turns

* docs: rm implementation spec

* fix(serve): cancel active turns during shutdown

* fix(runtime): protect settlement from waiter cancellation

* fix(backends): reject empty shell commands

* fix(runtime): terminate descendants after shell exit

* fix(mcp): keep standalone discovery off channel loop

* fix(cli): own and settle interactive prompt cancellation

* fix(serve): keep channel sends off runtime loop

* fix(stream): scope cancel context to iterator steps

* refactor(serve): require the owned async runtime

* fix(channels): keep interactive sends off runtime loop

* fix(selector): surface fallback without log spam

* test(runtime): normalize Windows shell marker

* fix(cli): serialize interactive session turns

* fix(shell): bound output drain after termination

* fix(ui): do not retry owned runtime failures

* fix(shell): allow signal-safe registry reentry

* fix(shell): avoid terminating reused process ids

* fix(channels): preserve streaming send order

* fix(cli): report runtime shutdown timeouts cleanly

* fix(mcp): guide async callers to async loader

* docs(runtime): clarify reserved async bridge APIs

* fix(runtime): bound code interpreter cleanup

* test(shell): use active Python for drain regression

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-07-27 14:17:57 +01:00

247 lines
7.1 KiB
Python

"""JSON-RPC client for imsg CLI.
Communicates with the imsg CLI via JSON-RPC over stdio,
similar to OpenClaw's approach.
"""
import asyncio
import json
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class RpcError:
"""RPC error response."""
code: int | None = None
message: str | None = None
data: Any = None
@dataclass
class RpcNotification:
"""RPC notification (no id, server-initiated)."""
method: str
params: Any = None
class ImsgRpcClient:
"""JSON-RPC client for imsg CLI.
Spawns `imsg rpc` as a subprocess and communicates via stdin/stdout.
Example:
client = ImsgRpcClient(cli_path="/usr/local/bin/imsg")
await client.start()
result = await client.request("send", {"to": "+1234", "text": "Hello"})
await client.stop()
"""
def __init__(
self,
cli_path: str = "imsg",
db_path: str | None = None,
on_notification: Callable[[RpcNotification], None] | None = None,
):
self.cli_path = cli_path
self.db_path = db_path
self.on_notification = on_notification
self._process: asyncio.subprocess.Process | None = None
self._next_id = 1
self._pending: dict[int, asyncio.Future] = {}
self._reader_task: asyncio.Task | None = None
self._stderr_task: asyncio.Task | None = None
self._closed = asyncio.Event()
async def start(self) -> None:
"""Start the imsg rpc subprocess."""
if self._process is not None:
return
args = [self.cli_path, "rpc"]
if self.db_path:
args.extend(["--db", self.db_path])
self._process = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._reader_task = asyncio.create_task(self._read_loop())
self._stderr_task = asyncio.create_task(self._stderr_loop())
logger.info(f"Started imsg rpc (pid={self._process.pid})")
async def stop(self) -> None:
"""Stop the imsg rpc subprocess."""
if self._process is None:
return
if self._process.stdin:
self._process.stdin.close()
if self._reader_task:
self._reader_task.cancel()
try:
await self._reader_task
except asyncio.CancelledError:
pass
if self._stderr_task:
self._stderr_task.cancel()
try:
await self._stderr_task
except asyncio.CancelledError:
pass
try:
self._process.terminate()
await asyncio.wait_for(self._process.wait(), timeout=2.0)
except TimeoutError:
self._process.kill()
await self._process.wait()
self._fail_all_pending(Exception("RPC client stopped"))
self._process = None
self._closed.set()
logger.info("Stopped imsg rpc")
async def wait_closed(self) -> None:
"""Wait for the client to close."""
await self._closed.wait()
async def request(
self,
method: str,
params: dict[str, Any] | None = None,
timeout: float = 10.0,
) -> Any:
"""Send a JSON-RPC request and wait for response.
Args:
method: RPC method name
params: Method parameters
timeout: Request timeout in seconds
Returns:
The result from the RPC response
Raises:
Exception: If request fails or times out
"""
if self._process is None or self._process.stdin is None:
raise Exception("RPC client not running")
request_id = self._next_id
self._next_id += 1
payload = {
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": params or {},
}
future: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending[request_id] = future
line = json.dumps(payload) + "\n"
self._process.stdin.write(line.encode())
await self._process.stdin.drain()
try:
return await asyncio.wait_for(future, timeout=timeout)
except TimeoutError:
self._pending.pop(request_id, None)
raise Exception(f"RPC request timeout: {method}") from None
async def _read_loop(self) -> None:
"""Read and process responses from stdout."""
if self._process is None or self._process.stdout is None:
return
while True:
try:
line = await self._process.stdout.readline()
if not line:
break
self._handle_line(line.decode().strip())
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error reading from imsg: {e}")
break
self._fail_all_pending(Exception("RPC connection closed"))
self._closed.set()
async def _stderr_loop(self) -> None:
"""Log stderr output."""
if self._process is None or self._process.stderr is None:
return
while True:
try:
line = await self._process.stderr.readline()
if not line:
break
logger.warning(f"imsg stderr: {line.decode().strip()}")
except asyncio.CancelledError:
break
except Exception:
break
def _handle_line(self, line: str) -> None:
"""Handle a single JSON-RPC response line."""
if not line:
return
try:
data = json.loads(line)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse RPC response: {e}")
return
# Check if it's a response (has id)
if "id" in data and data["id"] is not None:
request_id = data["id"]
future = self._pending.pop(request_id, None)
if future is None:
return
if data.get("error"):
error = data["error"]
msg = error.get("message", "RPC error")
future.set_exception(Exception(msg))
else:
future.set_result(data.get("result"))
return
# It's a notification
if "method" in data:
notification = RpcNotification(
method=data["method"],
params=data.get("params"),
)
if self.on_notification:
try:
self.on_notification(notification)
except Exception as e:
logger.error(f"Notification handler error: {e}")
def _fail_all_pending(self, error: Exception) -> None:
"""Fail all pending requests with an error."""
for future in self._pending.values():
if not future.done():
future.set_exception(error)
self._pending.clear()