From 25310c261bb227f0f7bd14ca721c485630bb4ed4 Mon Sep 17 00:00:00 2001 From: forceve Date: Mon, 16 Mar 2026 23:41:18 +0800 Subject: [PATCH 1/2] [4/7] ipc runtime PR3: IPC, listener=child/off, new run modes - Add vibemouse/ipc/: messages (LPJSON), client, server - Add shared/schema/ipc.schema.json, shared/protocol/COMMANDS.md, EVENTS.md - Add vibemouse agent run --listener=inline|child|off - Add vibemouse listener run --connect stdio - Wire VoiceMouseApp for listener_mode (inline/child/off) - listener=child: spawn listener subprocess, IPC over stdio - listener=off: no built-in listener - Add tests/ipc/ with message and client-server tests - status.json now includes listener_mode Made-with: Cursor --- shared/protocol/COMMANDS.md | 15 ++++ shared/protocol/EVENTS.md | 30 +++++++ shared/schema/ipc.schema.json | 47 ++++++++++ tests/core/test_app.py | 10 ++- tests/ipc/__init__.py | 1 + tests/ipc/test_client_server.py | 76 +++++++++++++++++ tests/ipc/test_messages.py | 92 ++++++++++++++++++++ vibemouse/cli/listener_cli.py | 112 ++++++++++++++++++++++++ vibemouse/cli/main.py | 61 +++++++++++-- vibemouse/core/app.py | 147 +++++++++++++++++++++++++------- vibemouse/ipc/__init__.py | 21 +++++ vibemouse/ipc/client.py | 85 ++++++++++++++++++ vibemouse/ipc/messages.py | 106 +++++++++++++++++++++++ vibemouse/ipc/server.py | 97 +++++++++++++++++++++ 14 files changed, 860 insertions(+), 40 deletions(-) create mode 100644 shared/protocol/COMMANDS.md create mode 100644 shared/protocol/EVENTS.md create mode 100644 shared/schema/ipc.schema.json create mode 100644 tests/ipc/__init__.py create mode 100644 tests/ipc/test_client_server.py create mode 100644 tests/ipc/test_messages.py create mode 100644 vibemouse/cli/listener_cli.py create mode 100644 vibemouse/ipc/__init__.py create mode 100644 vibemouse/ipc/client.py create mode 100644 vibemouse/ipc/messages.py create mode 100644 vibemouse/ipc/server.py diff --git a/shared/protocol/COMMANDS.md b/shared/protocol/COMMANDS.md new file mode 100644 index 0000000..dde04ea --- /dev/null +++ b/shared/protocol/COMMANDS.md @@ -0,0 +1,15 @@ +# VibeMouse Agent Commands + +Semantic commands that can be sent to the agent via IPC or resolved from input events via bindings. + +| Command | Description | +|---------|-------------| +| `noop` | No operation; event is ignored | +| `toggle_recording` | Start or stop voice recording | +| `trigger_secondary_action` | In idle: send Enter. In recording: stop and send transcript to OpenClaw | +| `submit_recording` | Stop recording and send transcript to OpenClaw | +| `send_enter` | Send Enter key to focused input | +| `workspace_left` | Switch workspace left (e.g. Hyprland) | +| `workspace_right` | Switch workspace right | +| `reload_config` | Reload config.json | +| `shutdown` | Gracefully shut down the agent | diff --git a/shared/protocol/EVENTS.md b/shared/protocol/EVENTS.md new file mode 100644 index 0000000..d2a5ee8 --- /dev/null +++ b/shared/protocol/EVENTS.md @@ -0,0 +1,30 @@ +# VibeMouse Normalized Input Events + +Device-agnostic event names produced by the listener and consumed by the binding resolver. + +## Mouse events + +| Event | Description | +|-------|-------------| +| `mouse.side_front.press` | Front side button press | +| `mouse.side_rear.press` | Rear side button press | + +## Keyboard events + +| Event | Description | +|-------|-------------| +| `hotkey.record_toggle` | Recording toggle hotkey (e.g. Ctrl+Alt+Space) | +| `hotkey.recording_submit` | Recording submit hotkey (optional) | + +## Gesture events + +| Event | Description | +|-------|-------------| +| `gesture.up` | Upward gesture | +| `gesture.down` | Downward gesture | +| `gesture.left` | Leftward gesture | +| `gesture.right` | Rightward gesture | + +## Event naming convention + +Events use dot-separated segments: `category.subcategory.action`. All lowercase, alphanumeric and underscore only. diff --git a/shared/schema/ipc.schema.json b/shared/schema/ipc.schema.json new file mode 100644 index 0000000..1f367f9 --- /dev/null +++ b/shared/schema/ipc.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://vibemouse.local/schema/ipc.schema.json", + "title": "VibeMouse IPC Messages", + "description": "LPJSON message types for agent-listener communication over stdio", + "oneOf": [ + { "$ref": "#/$defs/EventMessage" }, + { "$ref": "#/$defs/CommandMessage" } + ], + "$defs": { + "EventMessage": { + "type": "object", + "required": ["type", "event"], + "additionalProperties": false, + "properties": { + "type": { "const": "event" }, + "event": { + "type": "string", + "description": "Normalized input event name, e.g. mouse.side_front.press", + "pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$" + } + } + }, + "CommandMessage": { + "type": "object", + "required": ["type", "command"], + "additionalProperties": false, + "properties": { + "type": { "const": "command" }, + "command": { + "type": "string", + "enum": [ + "noop", + "reload_config", + "send_enter", + "shutdown", + "submit_recording", + "toggle_recording", + "trigger_secondary_action", + "workspace_left", + "workspace_right" + ] + } + } + } + } +} diff --git a/tests/core/test_app.py b/tests/core/test_app.py index de80c3c..66522c4 100644 --- a/tests/core/test_app.py +++ b/tests/core/test_app.py @@ -80,7 +80,10 @@ def test_set_recording_status_writes_recording_payload(self) -> None: dict[str, object], json.loads(status_file.read_text(encoding="utf-8")), ) - self.assertEqual(payload, {"recording": True, "state": "recording"}) + self.assertEqual( + payload, + {"recording": True, "state": "recording", "listener_mode": "inline"}, + ) def test_set_recording_status_writes_idle_payload(self) -> None: subject = self._make_subject() @@ -98,7 +101,10 @@ def test_set_recording_status_writes_idle_payload(self) -> None: dict[str, object], json.loads(status_file.read_text(encoding="utf-8")), ) - self.assertEqual(payload, {"recording": False, "state": "idle"}) + self.assertEqual( + payload, + {"recording": False, "state": "idle", "listener_mode": "inline"}, + ) class VoiceMouseAppButtonBehaviorTests(unittest.TestCase): diff --git a/tests/ipc/__init__.py b/tests/ipc/__init__.py new file mode 100644 index 0000000..3741d68 --- /dev/null +++ b/tests/ipc/__init__.py @@ -0,0 +1 @@ +# IPC tests diff --git a/tests/ipc/test_client_server.py b/tests/ipc/test_client_server.py new file mode 100644 index 0000000..844e390 --- /dev/null +++ b/tests/ipc/test_client_server.py @@ -0,0 +1,76 @@ +"""Tests for IPC client-server round-trip over pipes.""" + +from __future__ import annotations + +import io +import json +import struct +import time + +from vibemouse.ipc.client import IPCClient +from vibemouse.ipc.server import IPCServer + + +def test_ipc_client_send_event() -> None: + """IPCClient.send_event writes valid LPJSON to stdout (simulating pipe).""" + stdout_buf = io.BytesIO() + class PipeLike: + def __init__(self, buf: io.BytesIO) -> None: + self._buf = buf + def write(self, data: bytes) -> int: + return self._buf.write(data) + def flush(self) -> None: + pass + @property + def buffer(self) -> io.BytesIO: + return self._buf + # Actually IPCClient uses self._stdout.buffer.write - so we need stdout to have .buffer + # For a real pipe, sys.stdout has .buffer. For test we use a wrapper. + stdout_wrapper = PipeLike(stdout_buf) + stdin_buf = io.BytesIO() + stdin_wrapper = type("Stdin", (), {"buffer": stdin_buf})() + client = IPCClient(stdin=stdin_wrapper, stdout=stdout_wrapper) + client.send_event("hotkey.record_toggle") + data = stdout_buf.getvalue() + assert len(data) >= 4 + length, = struct.unpack(" None: + """IPCServer receives event from reader and calls on_event.""" + payload = {"type": "event", "event": "mouse.side_rear.press"} + body = json.dumps(payload).encode("utf-8") + frame = struct.pack(" None: + """IPCServer.send_command writes valid LPJSON to writer.""" + reader = io.BytesIO() # empty, no events + writer = io.BytesIO() + server = IPCServer( + reader=reader, + writer=writer, + on_event=lambda e: None, + ) + server.send_command("shutdown") + data = writer.getvalue() + assert len(data) >= 4 + length, = struct.unpack(" None: + msg = make_event_message("mouse.side_front.press") + assert msg == {"type": "event", "event": "mouse.side_front.press"} + + +def test_make_command_message() -> None: + msg = make_command_message("shutdown") + assert msg == {"type": "command", "command": "shutdown"} + + +def test_parse_event_message() -> None: + raw = {"type": "event", "event": "hotkey.record_toggle"} + msg = parse_message(raw) + assert msg["type"] == "event" + assert msg["event"] == "hotkey.record_toggle" + + +def test_parse_command_message() -> None: + raw = {"type": "command", "command": "reload_config"} + msg = parse_message(raw) + assert msg["type"] == "command" + assert msg["command"] == "reload_config" + + +def test_parse_message_invalid_type() -> None: + with pytest.raises(ValueError, match="Unknown message type"): + parse_message({"type": "unknown"}) + + +def test_parse_message_event_missing_field() -> None: + with pytest.raises(ValueError, match="event message must have"): + parse_message({"type": "event"}) + + +def test_serialize_message() -> None: + msg = make_event_message("gesture.left") + assert serialize_message(msg) == {"type": "event", "event": "gesture.left"} + + +def test_encode_decode_lpjson() -> None: + payload = {"type": "event", "event": "mouse.side_rear.press"} + frame = _encode_lpjson(payload) + assert len(frame) >= 4 + length, = struct.unpack(" None: + stream = io.BytesIO() + payload = {"type": "command", "command": "toggle_recording"} + write_lpjson_frame(stream, payload) + stream.seek(0) + frame = read_lpjson_frame(stream) + assert frame is not None + decoded = _decode_lpjson(frame) + assert decoded == payload + + +def test_read_lpjson_frame_eof() -> None: + stream = io.BytesIO() + frame = read_lpjson_frame(stream) + assert frame is None + + +def test_encode_lpjson_max_size_exceeded() -> None: + large = {"x": "a" * (1024 * 1024 + 1)} + with pytest.raises(ValueError, match="exceeds maximum"): + _encode_lpjson(large) diff --git a/vibemouse/cli/listener_cli.py b/vibemouse/cli/listener_cli.py new file mode 100644 index 0000000..b5ed2e4 --- /dev/null +++ b/vibemouse/cli/listener_cli.py @@ -0,0 +1,112 @@ +"""CLI entry point for standalone listener process (IPC client mode).""" + +from __future__ import annotations + +import argparse +import sys + +from vibemouse.config import load_config +from vibemouse.core.commands import ( + EVENT_HOTKEY_RECORDING_SUBMIT, + EVENT_HOTKEY_RECORD_TOGGLE, +) +from vibemouse.core.logging_setup import configure_logging +from vibemouse.ipc.client import IPCClient +from vibemouse.listener.keyboard_listener import KeyboardHotkeyListener +from vibemouse.listener.mouse_listener import SideButtonListener +from vibemouse.platform.system_integration import create_system_integration + + +def run_listener_connect_stdio(config_path: str | None = None) -> int: + """ + Run listener as standalone process, sending events via stdio (LPJSON). + Used when agent spawns listener with --listener=child. + """ + config = load_config(config_path) + configure_logging(config.log_level) + + client = IPCClient( + stdin=sys.stdin, + stdout=sys.stdout, + on_command=lambda cmd: _handle_command(client, cmd), + ) + + system_integration = create_system_integration() + mouse_listener = SideButtonListener( + on_event=client.send_event, + front_button=config.front_button, + rear_button=config.rear_button, + debounce_s=config.button_debounce_ms / 1000.0, + gestures_enabled=config.gestures_enabled, + gesture_trigger_button=config.gesture_trigger_button, + gesture_threshold_px=config.gesture_threshold_px, + gesture_freeze_pointer=config.gesture_freeze_pointer, + gesture_restore_cursor=config.gesture_restore_cursor, + system_integration=system_integration, + ) + keyboard_listener = KeyboardHotkeyListener( + on_event=client.send_event, + event_name=EVENT_HOTKEY_RECORD_TOGGLE, + keycodes=config.record_hotkey_keycodes, + debounce_s=config.button_debounce_ms / 1000.0, + ) + recording_submit_listener: KeyboardHotkeyListener | None = None + if config.recording_submit_keycode is not None: + recording_submit_listener = KeyboardHotkeyListener( + on_event=client.send_event, + event_name=EVENT_HOTKEY_RECORDING_SUBMIT, + keycodes=(config.recording_submit_keycode,), + debounce_s=config.button_debounce_ms / 1000.0, + ) + + mouse_listener.start() + keyboard_listener.start() + if recording_submit_listener is not None: + recording_submit_listener.start() + + try: + client.run() + finally: + client.stop() + mouse_listener.stop() + keyboard_listener.stop() + if recording_submit_listener is not None: + recording_submit_listener.stop() + + return 0 + + +def _handle_command(client: IPCClient, command: str) -> None: + if command == "shutdown": + client.stop() + try: + sys.stdin.close() + except Exception: + pass + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="vibemouse listener") + subparsers = parser.add_subparsers(dest="subcommand") + run_parser = subparsers.add_parser("run", help="run listener process") + run_parser.add_argument( + "--connect", + required=True, + choices=["stdio"], + help="IPC transport (stdio = LPJSON over stdin/stdout)", + ) + run_parser.add_argument( + "--config", + default=None, + help="path to config.json", + ) + args = parser.parse_args(argv) + + if args.subcommand != "run": + parser.print_help() + return 1 + if getattr(args, "connect", None) != "stdio": + run_parser.error("--connect stdio is required for listener run") + return 1 + + return run_listener_connect_stdio(config_path=getattr(args, "config", None)) diff --git a/vibemouse/cli/main.py b/vibemouse/cli/main.py index d524b3b..8d9461c 100644 --- a/vibemouse/cli/main.py +++ b/vibemouse/cli/main.py @@ -2,8 +2,9 @@ import argparse -from vibemouse.core.app import VoiceMouseApp from vibemouse.config import load_config +from vibemouse.config.store import resolve_config_path +from vibemouse.core.app import VoiceMouseApp from vibemouse.core.logging_setup import configure_logging from vibemouse.ops.deploy import configure_deploy_parser, run_deploy from vibemouse.ops.doctor import run_doctor @@ -12,9 +13,34 @@ def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="vibemouse") subparsers = parser.add_subparsers(dest="command") - _ = subparsers.add_parser("run", help="run the voice-input daemon") + + run_parser = subparsers.add_parser("run", help="run the voice-input daemon (alias for agent run --listener=inline)") + run_parser.add_argument("--config", default=None, help="path to config.json") + + agent_parser = subparsers.add_parser("agent", help="agent subcommands") + agent_sub = agent_parser.add_subparsers(dest="agent_command") + agent_run = agent_sub.add_parser("run", help="run the agent") + agent_run.add_argument( + "--listener", + choices=["inline", "child", "off"], + default="inline", + help="listener mode: inline (in-process), child (subprocess via IPC), off (no listener)", + ) + agent_run.add_argument("--config", default=None, help="path to config.json") + + listener_parser = subparsers.add_parser("listener", help="listener subcommands") + listener_sub = listener_parser.add_subparsers(dest="listener_command") + listener_run = listener_sub.add_parser("run", help="run listener process (for --connect stdio)") + listener_run.add_argument( + "--connect", + required=True, + choices=["stdio"], + help="IPC transport (stdio = LPJSON over stdin/stdout)", + ) + listener_run.add_argument("--config", default=None, help="path to config.json") + doctor_parser = subparsers.add_parser("doctor", help="run environment diagnostics") - _ = doctor_parser.add_argument( + doctor_parser.add_argument( "--fix", action="store_true", help="apply safe auto-remediations before running checks", @@ -33,6 +59,7 @@ def main(argv: list[str] | None = None) -> int: raw_command = getattr(args, "command", None) command = raw_command if isinstance(raw_command, str) else "run" + if command == "doctor": apply_fixes_raw = getattr(args, "fix", False) apply_fixes = bool(apply_fixes_raw) @@ -40,9 +67,33 @@ def main(argv: list[str] | None = None) -> int: if command == "deploy": return run_deploy(args) - config = load_config() + if command == "listener": + listener_cmd = getattr(args, "listener_command", None) + if listener_cmd != "run": + parser.parse_args([*((argv or [])[:0]), "listener", "--help"]) + return 1 + from vibemouse.cli.listener_cli import run_listener_connect_stdio + return run_listener_connect_stdio(config_path=getattr(args, "config", None)) + + if command == "agent": + agent_cmd = getattr(args, "agent_command", None) + if agent_cmd != "run": + parser.parse_args([*((argv or [])[:0]), "agent", "--help"]) + return 1 + listener_mode = getattr(args, "listener", "inline") + else: + # "run" - legacy alias for agent run --listener=inline + listener_mode = "inline" + config_path = getattr(args, "config", None) + + config = load_config(config_path) configure_logging(config.log_level) - app = VoiceMouseApp(config) + resolved_path = resolve_config_path(config_path) + app = VoiceMouseApp( + config, + listener_mode=listener_mode, + config_path=resolved_path if listener_mode == "child" else None, + ) app.run() return 0 diff --git a/vibemouse/core/app.py b/vibemouse/core/app.py index 735e15c..b3c68a6 100644 --- a/vibemouse/core/app.py +++ b/vibemouse/core/app.py @@ -2,6 +2,7 @@ import logging import subprocess +import sys import threading from pathlib import Path from typing import Literal @@ -24,6 +25,7 @@ from vibemouse.config import AppConfig, write_status from vibemouse.core.output import TextOutput from vibemouse.core.transcriber import SenseVoiceTranscriber +from vibemouse.ipc.server import IPCServer from vibemouse.listener.keyboard_listener import KeyboardHotkeyListener from vibemouse.listener.mouse_listener import SideButtonListener from vibemouse.platform.system_integration import ( @@ -32,16 +34,25 @@ ) +ListenerMode = Literal["inline", "child", "off"] TranscriptionTarget = Literal["default", "openclaw"] _LOG = logging.getLogger(__name__) class VoiceMouseApp: - def __init__(self, config: AppConfig) -> None: + def __init__( + self, + config: AppConfig, + *, + listener_mode: ListenerMode = "inline", + config_path: Path | str | None = None, + ) -> None: if config.front_button == config.rear_button: raise ValueError("Front and rear side buttons must be different") self._config: AppConfig = config + self._listener_mode: ListenerMode = listener_mode + self._config_path: Path | None = Path(config_path) if config_path else None self._system_integration: SystemIntegration = create_system_integration() self._recorder: AudioRecorder = AudioRecorder( sample_rate=config.sample_rate, @@ -58,32 +69,42 @@ def __init__(self, config: AppConfig) -> None: openclaw_retries=config.openclaw_retries, ) self._binding_resolver: BindingResolver = BindingResolver.from_config(config) - self._listener: SideButtonListener = SideButtonListener( - on_event=self._handle_input_event, - front_button=config.front_button, - rear_button=config.rear_button, - debounce_s=config.button_debounce_ms / 1000.0, - gestures_enabled=config.gestures_enabled, - gesture_trigger_button=config.gesture_trigger_button, - gesture_threshold_px=config.gesture_threshold_px, - gesture_freeze_pointer=config.gesture_freeze_pointer, - gesture_restore_cursor=config.gesture_restore_cursor, - system_integration=self._system_integration, - ) - self._keyboard_listener: KeyboardHotkeyListener = KeyboardHotkeyListener( - on_event=self._handle_input_event, - event_name=EVENT_HOTKEY_RECORD_TOGGLE, - keycodes=config.record_hotkey_keycodes, - debounce_s=config.button_debounce_ms / 1000.0, - ) + self._listener: SideButtonListener | None = None + self._keyboard_listener: KeyboardHotkeyListener | None = None self._recording_submit_listener: KeyboardHotkeyListener | None = None - if config.recording_submit_keycode is not None: - self._recording_submit_listener = KeyboardHotkeyListener( + self._ipc_server: IPCServer | None = None + self._listener_process: subprocess.Popen | None = None + + if listener_mode == "inline": + self._listener = SideButtonListener( + on_event=self._handle_input_event, + front_button=config.front_button, + rear_button=config.rear_button, + debounce_s=config.button_debounce_ms / 1000.0, + gestures_enabled=config.gestures_enabled, + gesture_trigger_button=config.gesture_trigger_button, + gesture_threshold_px=config.gesture_threshold_px, + gesture_freeze_pointer=config.gesture_freeze_pointer, + gesture_restore_cursor=config.gesture_restore_cursor, + system_integration=self._system_integration, + ) + self._keyboard_listener = KeyboardHotkeyListener( on_event=self._handle_input_event, - event_name=EVENT_HOTKEY_RECORDING_SUBMIT, - keycodes=(config.recording_submit_keycode,), + event_name=EVENT_HOTKEY_RECORD_TOGGLE, + keycodes=config.record_hotkey_keycodes, debounce_s=config.button_debounce_ms / 1000.0, ) + if config.recording_submit_keycode is not None: + self._recording_submit_listener = KeyboardHotkeyListener( + on_event=self._handle_input_event, + event_name=EVENT_HOTKEY_RECORDING_SUBMIT, + keycodes=(config.recording_submit_keycode,), + debounce_s=config.button_debounce_ms / 1000.0, + ) + elif listener_mode == "child": + pass # IPC server and subprocess started in run() + # listener_mode == "off": no listeners + self._stop_event: threading.Event = threading.Event() self._transcribe_lock: threading.Lock = threading.Lock() self._workers_lock: threading.Lock = threading.Lock() @@ -91,11 +112,16 @@ def __init__(self, config: AppConfig) -> None: self._prewarm_started: bool = False def run(self) -> None: - self._listener.start() - self._keyboard_listener.start() - if self._recording_submit_listener is not None: - self._recording_submit_listener.start() - self._set_recording_status(False) + if self._listener_mode == "child": + self._start_listener_child() + elif self._listener_mode == "inline": + assert self._listener is not None and self._keyboard_listener is not None + self._listener.start() + self._keyboard_listener.start() + if self._recording_submit_listener is not None: + self._recording_submit_listener.start() + # listener=off: no listeners + self._set_recording_status(False, listener_mode=self._listener_mode) recording_submit_hotkey = self._config.recording_submit_keycode _LOG.info( "VibeMouse ready. " @@ -111,7 +137,8 @@ def run(self) -> None: + f"gesture_freeze_pointer={self._config.gesture_freeze_pointer}, " + f"gesture_restore_cursor={self._config.gesture_restore_cursor}, " + f"prewarm_on_start={self._config.prewarm_on_start}, " - + f"prewarm_delay_s={self._config.prewarm_delay_s}. " + + f"prewarm_delay_s={self._config.prewarm_delay_s}, " + + f"listener_mode={self._listener_mode}. " + "Press side-front to start/stop recording. While recording, side-rear sends transcript to OpenClaw; otherwise side-rear sends Enter." ) self._maybe_prewarm_transcriber() @@ -123,8 +150,20 @@ def run(self) -> None: self.shutdown() def shutdown(self) -> None: - self._listener.stop() - self._keyboard_listener.stop() + if self._ipc_server is not None: + self._ipc_server.send_command("shutdown") + self._ipc_server.stop() + self._ipc_server = None + if self._listener_process is not None: + try: + self._listener_process.wait(timeout=3) + except subprocess.TimeoutExpired: + self._listener_process.kill() + self._listener_process = None + if self._listener is not None: + self._listener.stop() + if self._keyboard_listener is not None: + self._keyboard_listener.stop() if self._recording_submit_listener is not None: self._recording_submit_listener.stop() self._recorder.cancel() @@ -469,10 +508,52 @@ def _prewarm_transcriber(self, delay_s: float = 0.0) -> None: except Exception as error: _LOG.warning("Transcriber prewarm skipped: %s", error) - def _set_recording_status(self, is_recording: bool) -> None: - payload = { + def _start_listener_child(self) -> None: + """Spawn listener as subprocess and start IPC server to receive events.""" + cmd = [ + sys.executable, + "-m", + "vibemouse.cli.main", + "listener", + "run", + "--connect", + "stdio", + ] + if self._config_path is not None: + cmd.extend(["--config", str(self._config_path)]) + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=0, + ) + self._listener_process = proc + if proc.stdin is None or proc.stdout is None: + raise RuntimeError("Failed to create listener subprocess pipes") + self._ipc_server = IPCServer( + reader=proc.stdout, + writer=proc.stdin, + on_event=self._handle_input_event, + ) + self._ipc_server.start() + _LOG.info("Listener child process started (listener_mode=child)") + + def _set_recording_status( + self, + is_recording: bool, + *, + listener_mode: ListenerMode | None = None, + ) -> None: + mode = ( + listener_mode + if listener_mode is not None + else getattr(self, "_listener_mode", "inline") + ) + payload: dict[str, object] = { "recording": is_recording, "state": "recording" if is_recording else "idle", + "listener_mode": mode, } try: write_status(self._config.status_file, payload) diff --git a/vibemouse/ipc/__init__.py b/vibemouse/ipc/__init__.py new file mode 100644 index 0000000..a863974 --- /dev/null +++ b/vibemouse/ipc/__init__.py @@ -0,0 +1,21 @@ +"""IPC module for agent-listener communication using stdio + LPJSON.""" + +from vibemouse.ipc.client import IPCClient +from vibemouse.ipc.messages import ( + CommandMessage, + EventMessage, + Message, + parse_message, + serialize_message, +) +from vibemouse.ipc.server import IPCServer + +__all__ = [ + "CommandMessage", + "EventMessage", + "IPCClient", + "IPCServer", + "Message", + "parse_message", + "serialize_message", +] diff --git a/vibemouse/ipc/client.py b/vibemouse/ipc/client.py new file mode 100644 index 0000000..f1e6a5a --- /dev/null +++ b/vibemouse/ipc/client.py @@ -0,0 +1,85 @@ +"""IPC client for connecting to agent via stdio (LPJSON).""" + +from __future__ import annotations + +import json +import logging +import struct +import sys +from typing import Any, Callable + +from vibemouse.ipc.messages import ( + CommandMessage, + EventMessage, + Message, + _decode_lpjson, + _encode_lpjson, + make_event_message, + parse_message, +) + +_LOG = logging.getLogger(__name__) + +_LENGTH_PREFIX_SIZE = 4 +_MAX_MESSAGE_SIZE = 1024 * 1024 + + +class IPCClient: + """ + Client that sends events to agent and receives commands from agent + over stdio using LPJSON framing. + """ + + def __init__( + self, + *, + stdin: Any = None, + stdout: Any = None, + on_command: Callable[[str], None] | None = None, + ) -> None: + self._stdin = stdin if stdin is not None else sys.stdin + self._stdout = stdout if stdout is not None else sys.stdout + self._on_command = on_command + self._buffer = bytearray() + self._running = False + + def send_event(self, event_name: str) -> None: + """Send an event message to the agent.""" + msg = make_event_message(event_name) + frame = _encode_lpjson(msg) + self._stdout.buffer.write(frame) + self._stdout.buffer.flush() + + def run(self) -> None: + """Run the client loop: read commands from stdin, dispatch to on_command.""" + self._running = True + while self._running: + try: + prefix = self._stdin.buffer.read(_LENGTH_PREFIX_SIZE) + if len(prefix) == 0: + break + if len(prefix) < _LENGTH_PREFIX_SIZE: + _LOG.warning("Truncated length prefix") + break + length, = struct.unpack(" _MAX_MESSAGE_SIZE: + _LOG.error("Message size %d exceeds maximum", length) + break + body = self._stdin.buffer.read(length) + if len(body) < length: + _LOG.warning("Truncated payload") + break + raw = json.loads(body.decode("utf-8")) + msg = parse_message(raw) + if msg.get("type") == "command": + cmd = msg.get("command", "") + if self._on_command is not None: + self._on_command(cmd) + except Exception as error: + _LOG.exception("IPC client error: %s", error) + break + self._running = False + + def stop(self) -> None: + """Stop the client loop.""" + self._running = False diff --git a/vibemouse/ipc/messages.py b/vibemouse/ipc/messages.py new file mode 100644 index 0000000..41f27a5 --- /dev/null +++ b/vibemouse/ipc/messages.py @@ -0,0 +1,106 @@ +"""IPC message types and LPJSON framing for agent-listener communication.""" + +from __future__ import annotations + +import json +import struct +from typing import Any, Literal + +# LPJSON: 4-byte little-endian unsigned length prefix + UTF-8 JSON payload +_LENGTH_PREFIX_SIZE = 4 +_MAX_MESSAGE_SIZE = 1024 * 1024 # 1 MiB + + +def _encode_lpjson(payload: dict[str, Any]) -> bytes: + """Encode a JSON object as LPJSON frame.""" + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + if len(body) > _MAX_MESSAGE_SIZE: + raise ValueError( + f"Message size {len(body)} exceeds maximum {_MAX_MESSAGE_SIZE}" + ) + return struct.pack(" dict[str, Any]: + """Decode LPJSON frame to JSON object.""" + if len(data) < _LENGTH_PREFIX_SIZE: + raise ValueError("Incomplete length prefix") + length, = struct.unpack(" _MAX_MESSAGE_SIZE: + raise ValueError( + f"Declared message size {length} exceeds maximum {_MAX_MESSAGE_SIZE}" + ) + if len(data) < _LENGTH_PREFIX_SIZE + length: + raise ValueError( + f"Expected {length} bytes payload, got {len(data) - _LENGTH_PREFIX_SIZE}" + ) + body = data[_LENGTH_PREFIX_SIZE : _LENGTH_PREFIX_SIZE + length] + return json.loads(body.decode("utf-8")) + + +def read_lpjson_frame(stream: Any) -> bytes | None: + """ + Read one LPJSON frame from a stream. + Returns None on EOF, raises on invalid data. + """ + prefix = stream.read(_LENGTH_PREFIX_SIZE) + if len(prefix) == 0: + return None + if len(prefix) < _LENGTH_PREFIX_SIZE: + raise ValueError("Truncated length prefix") + length, = struct.unpack(" _MAX_MESSAGE_SIZE: + raise ValueError( + f"Declared message size {length} exceeds maximum {_MAX_MESSAGE_SIZE}" + ) + body = stream.read(length) + if len(body) < length: + raise ValueError( + f"Expected {length} bytes payload, got {len(body)}" + ) + return prefix + body + + +def write_lpjson_frame(stream: Any, payload: dict[str, Any]) -> None: + """Write one LPJSON frame to a stream.""" + frame = _encode_lpjson(payload) + stream.write(frame) + stream.flush() + + +# --- Message types --- + +EventMessage = dict[str, Any] # {"type":"event","event":"mouse.side_front.press"} +CommandMessage = dict[str, Any] # {"type":"command","command":"shutdown"} +Message = EventMessage | CommandMessage + + +def parse_message(raw: dict[str, Any]) -> Message: + """Parse a raw JSON object into a typed message.""" + msg_type = raw.get("type") + if msg_type == "event": + event = raw.get("event") + if not isinstance(event, str): + raise ValueError("event message must have string 'event' field") + return {"type": "event", "event": event} + if msg_type == "command": + command = raw.get("command") + if not isinstance(command, str): + raise ValueError("command message must have string 'command' field") + return {"type": "command", "command": command} + raise ValueError(f"Unknown message type: {msg_type!r}") + + +def serialize_message(msg: Message) -> dict[str, Any]: + """Serialize a message to a JSON-serializable dict.""" + return dict(msg) + + +def make_event_message(event_name: str) -> EventMessage: + """Create an event message.""" + return {"type": "event", "event": event_name} + + +def make_command_message(command_name: str) -> CommandMessage: + """Create a command message.""" + return {"type": "command", "command": command_name} diff --git a/vibemouse/ipc/server.py b/vibemouse/ipc/server.py new file mode 100644 index 0000000..c05335e --- /dev/null +++ b/vibemouse/ipc/server.py @@ -0,0 +1,97 @@ +"""IPC server for agent to receive events from listener child via stdio (LPJSON).""" + +from __future__ import annotations + +import json +import logging +import struct +import threading +from typing import Any, Callable + +from vibemouse.ipc.messages import ( + EventMessage, + Message, + _decode_lpjson, + _encode_lpjson, + make_command_message, + parse_message, +) + +_LOG = logging.getLogger(__name__) + +_LENGTH_PREFIX_SIZE = 4 +_MAX_MESSAGE_SIZE = 1024 * 1024 + + +class IPCServer: + """ + Server that reads events from a listener child's stdout and optionally + sends commands to the listener's stdin. + """ + + def __init__( + self, + *, + reader: Any, + writer: Any | None = None, + on_event: Callable[[str], None], + ) -> None: + self._reader = reader + self._writer = writer + self._on_event = on_event + self._running = False + self._thread: threading.Thread | None = None + + def start(self) -> None: + """Start the server loop in a background thread.""" + if self._thread is not None and self._thread.is_alive(): + return + self._running = True + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the server loop.""" + self._running = False + if self._thread is not None: + self._thread.join(timeout=2) + + def send_command(self, command_name: str) -> None: + """Send a command to the listener (if writer is configured).""" + if self._writer is None: + return + msg = make_command_message(command_name) + frame = _encode_lpjson(msg) + try: + self._writer.write(frame) + self._writer.flush() + except Exception as error: + _LOG.warning("Failed to send command to listener: %s", error) + + def _run(self) -> None: + while self._running: + try: + prefix = self._reader.read(_LENGTH_PREFIX_SIZE) + if len(prefix) == 0: + break + if len(prefix) < _LENGTH_PREFIX_SIZE: + _LOG.warning("Truncated length prefix from listener") + break + length, = struct.unpack(" _MAX_MESSAGE_SIZE: + _LOG.error("Message size %d exceeds maximum", length) + break + body = self._reader.read(length) + if len(body) < length: + _LOG.warning("Truncated payload from listener") + break + raw = json.loads(body.decode("utf-8")) + msg = parse_message(raw) + if msg.get("type") == "event": + event_name = msg.get("event", "") + self._on_event(event_name) + except Exception as error: + if self._running: + _LOG.exception("IPC server error: %s", error) + break + self._running = False From ee210f122b895e1cdf12203d516ff61188becc4a Mon Sep 17 00:00:00 2001 From: forceve Date: Tue, 17 Mar 2026 00:14:45 +0800 Subject: [PATCH 2/2] [4/7] complete PR3 command IPC path --- tests/cli/test_main.py | 32 +++++ tests/core/test_app.py | 62 ++++++++- tests/ipc/test_client_server.py | 55 +++++--- vibemouse/cli/main.py | 2 +- vibemouse/core/app.py | 220 +++++++++++++++++++++----------- vibemouse/ipc/__init__.py | 11 +- vibemouse/ipc/client.py | 45 +++---- vibemouse/ipc/messages.py | 10 ++ vibemouse/ipc/server.py | 173 ++++++++++++++++++++----- 9 files changed, 461 insertions(+), 149 deletions(-) diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index a5c0d74..eb5de4b 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -33,6 +33,7 @@ def test_default_invocation_runs_app(self) -> None: cfg = SimpleNamespace(log_level="INFO") with ( patch("vibemouse.main.load_config", return_value=cfg) as load_config, + patch("vibemouse.main.resolve_config_path", return_value="/tmp/config.json"), patch( "vibemouse.main.VoiceMouseApp", return_value=app_instance ) as app_ctor, @@ -42,6 +43,13 @@ def test_default_invocation_runs_app(self) -> None: self.assertEqual(rc, 0) self.assertEqual(load_config.call_count, 1) self.assertEqual(app_ctor.call_count, 1) + self.assertEqual( + app_ctor.call_args.kwargs, + { + "listener_mode": "inline", + "config_path": "/tmp/config.json", + }, + ) self.assertEqual(app_instance.run.call_count, 1) def test_explicit_run_subcommand_runs_app(self) -> None: @@ -49,6 +57,7 @@ def test_explicit_run_subcommand_runs_app(self) -> None: cfg = SimpleNamespace(log_level="INFO") with ( patch("vibemouse.main.load_config", return_value=cfg), + patch("vibemouse.main.resolve_config_path", return_value="/tmp/config.json"), patch("vibemouse.main.VoiceMouseApp", return_value=app_instance), ): rc = main(["run"]) @@ -56,6 +65,29 @@ def test_explicit_run_subcommand_runs_app(self) -> None: self.assertEqual(rc, 0) self.assertEqual(app_instance.run.call_count, 1) + def test_agent_run_off_mode_is_forwarded_to_app(self) -> None: + app_instance = MagicMock() + cfg = SimpleNamespace(log_level="INFO") + with ( + patch("vibemouse.main.load_config", return_value=cfg), + patch( + "vibemouse.main.resolve_config_path", + return_value="/tmp/agent-config.json", + ), + patch("vibemouse.main.VoiceMouseApp", return_value=app_instance) as app_ctor, + ): + rc = main(["agent", "run", "--listener", "off"]) + + self.assertEqual(rc, 0) + self.assertEqual( + app_ctor.call_args.kwargs, + { + "listener_mode": "off", + "config_path": "/tmp/agent-config.json", + }, + ) + self.assertEqual(app_instance.run.call_count, 1) + def test_deploy_subcommand_dispatches_to_deploy(self) -> None: with ( patch("vibemouse.main.run_deploy", return_value=5) as run_deploy, diff --git a/tests/core/test_app.py b/tests/core/test_app.py index 66522c4..951872a 100644 --- a/tests/core/test_app.py +++ b/tests/core/test_app.py @@ -11,7 +11,12 @@ from typing import cast from unittest.mock import patch -from vibemouse.core.commands import COMMAND_SEND_ENTER, EVENT_MOUSE_SIDE_FRONT_PRESS +from vibemouse.core.commands import ( + COMMAND_RELOAD_CONFIG, + COMMAND_SEND_ENTER, + COMMAND_SHUTDOWN, + EVENT_MOUSE_SIDE_FRONT_PRESS, +) from vibemouse.app import VoiceMouseApp @@ -106,6 +111,33 @@ def test_set_recording_status_writes_idle_payload(self) -> None: {"recording": False, "state": "idle", "listener_mode": "inline"}, ) + def test_set_recording_status_includes_ipc_port_when_command_server_is_running(self) -> None: + subject = self._make_subject() + with tempfile.TemporaryDirectory(prefix="vibemouse-status-") as tmp: + status_file = Path(tmp) / "status.json" + setattr(subject, "_config", SimpleNamespace(status_file=status_file)) + setattr(subject, "_command_server", SimpleNamespace(port=43125)) + + set_status = cast( + Callable[[bool], None], + getattr(subject, "_set_recording_status"), + ) + set_status(False) + + payload = cast( + dict[str, object], + json.loads(status_file.read_text(encoding="utf-8")), + ) + self.assertEqual( + payload, + { + "ipc_port": 43125, + "listener_mode": "inline", + "recording": False, + "state": "idle", + }, + ) + class VoiceMouseAppButtonBehaviorTests(unittest.TestCase): @staticmethod @@ -310,6 +342,34 @@ def test_handle_input_event_routes_through_binding_resolver(self) -> None: self.assertEqual(send_enter_calls, ["enter"]) + def test_execute_command_reload_config_dispatches_to_reload_handler(self) -> None: + subject = self._make_subject() + reload_calls: list[bool] = [] + setattr(subject, "_command_lock", threading.RLock()) + setattr(subject, "_reload_config", lambda: reload_calls.append(True)) + + execute_command = cast( + Callable[[str], None], + getattr(subject, "_execute_command"), + ) + execute_command(COMMAND_RELOAD_CONFIG) + + self.assertEqual(reload_calls, [True]) + + def test_execute_command_shutdown_sets_stop_event(self) -> None: + subject = self._make_subject() + stop_event = threading.Event() + setattr(subject, "_command_lock", threading.RLock()) + setattr(subject, "_stop_event", stop_event) + + execute_command = cast( + Callable[[str], None], + getattr(subject, "_execute_command"), + ) + execute_command(COMMAND_SHUTDOWN) + + self.assertTrue(stop_event.is_set()) + def test_transcribe_and_output_openclaw_uses_openclaw_sender(self) -> None: subject = self._make_subject() recording = SimpleNamespace(duration_s=1.0, path=Path("/tmp/transcribe.wav")) diff --git a/tests/ipc/test_client_server.py b/tests/ipc/test_client_server.py index 844e390..a4382db 100644 --- a/tests/ipc/test_client_server.py +++ b/tests/ipc/test_client_server.py @@ -4,32 +4,21 @@ import io import json +import socket import struct +import threading import time from vibemouse.ipc.client import IPCClient -from vibemouse.ipc.server import IPCServer +from vibemouse.ipc.messages import make_command_message, write_lpjson_frame +from vibemouse.ipc.server import AgentCommandServer, IPCServer def test_ipc_client_send_event() -> None: """IPCClient.send_event writes valid LPJSON to stdout (simulating pipe).""" stdout_buf = io.BytesIO() - class PipeLike: - def __init__(self, buf: io.BytesIO) -> None: - self._buf = buf - def write(self, data: bytes) -> int: - return self._buf.write(data) - def flush(self) -> None: - pass - @property - def buffer(self) -> io.BytesIO: - return self._buf - # Actually IPCClient uses self._stdout.buffer.write - so we need stdout to have .buffer - # For a real pipe, sys.stdout has .buffer. For test we use a wrapper. - stdout_wrapper = PipeLike(stdout_buf) stdin_buf = io.BytesIO() - stdin_wrapper = type("Stdin", (), {"buffer": stdin_buf})() - client = IPCClient(stdin=stdin_wrapper, stdout=stdout_wrapper) + client = IPCClient(stdin=stdin_buf, stdout=stdout_buf) client.send_event("hotkey.record_toggle") data = stdout_buf.getvalue() assert len(data) >= 4 @@ -74,3 +63,37 @@ def test_ipc_server_send_command() -> None: assert length == len(data) - 4 payload = json.loads(data[4:].decode("utf-8")) assert payload == {"type": "command", "command": "shutdown"} + + +def test_ipc_server_receives_command_when_handler_is_configured() -> None: + payload = {"type": "command", "command": "reload_config"} + body = json.dumps(payload).encode("utf-8") + frame = struct.pack(" None: + received: list[str] = [] + ready = threading.Event() + + def on_command(command_name: str) -> None: + received.append(command_name) + ready.set() + + server = AgentCommandServer(on_command=on_command) + server.start() + try: + with socket.create_connection(("127.0.0.1", server.port), timeout=2) as conn: + stream = conn.makefile("rwb") + write_lpjson_frame(stream, make_command_message("shutdown")) + stream.close() + assert ready.wait(timeout=2) + assert received == ["shutdown"] + finally: + server.stop() diff --git a/vibemouse/cli/main.py b/vibemouse/cli/main.py index 8d9461c..3a48d3e 100644 --- a/vibemouse/cli/main.py +++ b/vibemouse/cli/main.py @@ -92,7 +92,7 @@ def main(argv: list[str] | None = None) -> int: app = VoiceMouseApp( config, listener_mode=listener_mode, - config_path=resolved_path if listener_mode == "child" else None, + config_path=resolved_path, ) app.run() return 0 diff --git a/vibemouse/core/app.py b/vibemouse/core/app.py index b3c68a6..e48e784 100644 --- a/vibemouse/core/app.py +++ b/vibemouse/core/app.py @@ -12,7 +12,9 @@ from vibemouse.core.audio import AudioRecorder, AudioRecording from vibemouse.core.commands import ( COMMAND_NOOP, + COMMAND_RELOAD_CONFIG, COMMAND_SEND_ENTER, + COMMAND_SHUTDOWN, COMMAND_SUBMIT_RECORDING, COMMAND_TOGGLE_RECORDING, COMMAND_TRIGGER_SECONDARY_ACTION, @@ -22,10 +24,10 @@ EVENT_HOTKEY_RECORD_TOGGLE, gesture_direction_to_event, ) -from vibemouse.config import AppConfig, write_status +from vibemouse.config import AppConfig, load_config, write_status from vibemouse.core.output import TextOutput from vibemouse.core.transcriber import SenseVoiceTranscriber -from vibemouse.ipc.server import IPCServer +from vibemouse.ipc.server import AgentCommandServer, IPCServer from vibemouse.listener.keyboard_listener import KeyboardHotkeyListener from vibemouse.listener.mouse_listener import SideButtonListener from vibemouse.platform.system_integration import ( @@ -54,73 +56,24 @@ def __init__( self._listener_mode: ListenerMode = listener_mode self._config_path: Path | None = Path(config_path) if config_path else None self._system_integration: SystemIntegration = create_system_integration() - self._recorder: AudioRecorder = AudioRecorder( - sample_rate=config.sample_rate, - channels=config.channels, - dtype=config.dtype, - temp_dir=config.temp_dir, - ) - self._transcriber: SenseVoiceTranscriber = SenseVoiceTranscriber(config) - self._output: TextOutput = TextOutput( - system_integration=self._system_integration, - openclaw_command=config.openclaw_command, - openclaw_agent=config.openclaw_agent, - openclaw_timeout_s=config.openclaw_timeout_s, - openclaw_retries=config.openclaw_retries, - ) - self._binding_resolver: BindingResolver = BindingResolver.from_config(config) self._listener: SideButtonListener | None = None self._keyboard_listener: KeyboardHotkeyListener | None = None self._recording_submit_listener: KeyboardHotkeyListener | None = None self._ipc_server: IPCServer | None = None self._listener_process: subprocess.Popen | None = None - - if listener_mode == "inline": - self._listener = SideButtonListener( - on_event=self._handle_input_event, - front_button=config.front_button, - rear_button=config.rear_button, - debounce_s=config.button_debounce_ms / 1000.0, - gestures_enabled=config.gestures_enabled, - gesture_trigger_button=config.gesture_trigger_button, - gesture_threshold_px=config.gesture_threshold_px, - gesture_freeze_pointer=config.gesture_freeze_pointer, - gesture_restore_cursor=config.gesture_restore_cursor, - system_integration=self._system_integration, - ) - self._keyboard_listener = KeyboardHotkeyListener( - on_event=self._handle_input_event, - event_name=EVENT_HOTKEY_RECORD_TOGGLE, - keycodes=config.record_hotkey_keycodes, - debounce_s=config.button_debounce_ms / 1000.0, - ) - if config.recording_submit_keycode is not None: - self._recording_submit_listener = KeyboardHotkeyListener( - on_event=self._handle_input_event, - event_name=EVENT_HOTKEY_RECORDING_SUBMIT, - keycodes=(config.recording_submit_keycode,), - debounce_s=config.button_debounce_ms / 1000.0, - ) - elif listener_mode == "child": - pass # IPC server and subprocess started in run() - # listener_mode == "off": no listeners + self._command_server: AgentCommandServer | None = None self._stop_event: threading.Event = threading.Event() self._transcribe_lock: threading.Lock = threading.Lock() self._workers_lock: threading.Lock = threading.Lock() self._workers: set[threading.Thread] = set() self._prewarm_started: bool = False + self._command_lock: threading.RLock = threading.RLock() + self._configure_runtime(config) def run(self) -> None: - if self._listener_mode == "child": - self._start_listener_child() - elif self._listener_mode == "inline": - assert self._listener is not None and self._keyboard_listener is not None - self._listener.start() - self._keyboard_listener.start() - if self._recording_submit_listener is not None: - self._recording_submit_listener.start() - # listener=off: no listeners + self._start_command_server() + self._start_listener_mode() self._set_recording_status(False, listener_mode=self._listener_mode) recording_submit_hotkey = self._config.recording_submit_keycode _LOG.info( @@ -150,22 +103,10 @@ def run(self) -> None: self.shutdown() def shutdown(self) -> None: - if self._ipc_server is not None: - self._ipc_server.send_command("shutdown") - self._ipc_server.stop() - self._ipc_server = None - if self._listener_process is not None: - try: - self._listener_process.wait(timeout=3) - except subprocess.TimeoutExpired: - self._listener_process.kill() - self._listener_process = None - if self._listener is not None: - self._listener.stop() - if self._keyboard_listener is not None: - self._keyboard_listener.stop() - if self._recording_submit_listener is not None: - self._recording_submit_listener.stop() + self._stop_listener_mode() + if self._command_server is not None: + self._command_server.stop() + self._command_server = None self._recorder.cancel() self._set_recording_status(False) with self._workers_lock: @@ -220,6 +161,19 @@ def _execute_command( command_name: str, *, source_event: str | None = None, + ) -> None: + command_lock = getattr(self, "_command_lock", None) + if command_lock is None: + self._execute_command_unlocked(command_name, source_event=source_event) + return + with command_lock: + self._execute_command_unlocked(command_name, source_event=source_event) + + def _execute_command_unlocked( + self, + command_name: str, + *, + source_event: str | None = None, ) -> None: if source_event is not None: _LOG.debug("Resolved input event '%s' -> '%s'", source_event, command_name) @@ -246,6 +200,12 @@ def _execute_command( if command_name == COMMAND_WORKSPACE_RIGHT: self._dispatch_workspace_command("right") return + if command_name == COMMAND_RELOAD_CONFIG: + self._reload_config() + return + if command_name == COMMAND_SHUTDOWN: + self._request_shutdown() + return _LOG.warning("Ignoring unsupported command '%s'", command_name) @@ -539,6 +499,119 @@ def _start_listener_child(self) -> None: self._ipc_server.start() _LOG.info("Listener child process started (listener_mode=child)") + def _configure_runtime(self, config: AppConfig) -> None: + self._config = config + self._binding_resolver = BindingResolver.from_config(config) + self._recorder = AudioRecorder( + sample_rate=config.sample_rate, + channels=config.channels, + dtype=config.dtype, + temp_dir=config.temp_dir, + ) + self._transcriber = SenseVoiceTranscriber(config) + self._output = TextOutput( + system_integration=self._system_integration, + openclaw_command=config.openclaw_command, + openclaw_agent=config.openclaw_agent, + openclaw_timeout_s=config.openclaw_timeout_s, + openclaw_retries=config.openclaw_retries, + ) + self._listener = None + self._keyboard_listener = None + self._recording_submit_listener = None + if self._listener_mode == "inline": + self._listener = SideButtonListener( + on_event=self._handle_input_event, + front_button=config.front_button, + rear_button=config.rear_button, + debounce_s=config.button_debounce_ms / 1000.0, + gestures_enabled=config.gestures_enabled, + gesture_trigger_button=config.gesture_trigger_button, + gesture_threshold_px=config.gesture_threshold_px, + gesture_freeze_pointer=config.gesture_freeze_pointer, + gesture_restore_cursor=config.gesture_restore_cursor, + system_integration=self._system_integration, + ) + self._keyboard_listener = KeyboardHotkeyListener( + on_event=self._handle_input_event, + event_name=EVENT_HOTKEY_RECORD_TOGGLE, + keycodes=config.record_hotkey_keycodes, + debounce_s=config.button_debounce_ms / 1000.0, + ) + if config.recording_submit_keycode is not None: + self._recording_submit_listener = KeyboardHotkeyListener( + on_event=self._handle_input_event, + event_name=EVENT_HOTKEY_RECORDING_SUBMIT, + keycodes=(config.recording_submit_keycode,), + debounce_s=config.button_debounce_ms / 1000.0, + ) + + def _start_command_server(self) -> None: + if self._command_server is not None: + return + self._command_server = AgentCommandServer(on_command=self._execute_command) + self._command_server.start() + _LOG.info("Agent command server listening on 127.0.0.1:%s", self._command_server.port) + + def _start_listener_mode(self) -> None: + if self._listener_mode == "child": + self._start_listener_child() + return + if self._listener_mode == "inline": + assert self._listener is not None and self._keyboard_listener is not None + self._listener.start() + self._keyboard_listener.start() + if self._recording_submit_listener is not None: + self._recording_submit_listener.start() + + def _stop_listener_mode(self) -> None: + if self._ipc_server is not None: + self._ipc_server.send_command(COMMAND_SHUTDOWN) + self._ipc_server.stop() + self._ipc_server = None + if self._listener_process is not None: + try: + self._listener_process.wait(timeout=3) + except subprocess.TimeoutExpired: + self._listener_process.kill() + self._listener_process = None + if self._listener is not None: + self._listener.stop() + self._listener = None + if self._keyboard_listener is not None: + self._keyboard_listener.stop() + self._keyboard_listener = None + if self._recording_submit_listener is not None: + self._recording_submit_listener.stop() + self._recording_submit_listener = None + + def _reload_config(self) -> None: + if self._recorder.is_recording: + _LOG.warning("Ignoring reload_config while recording is active") + return + with self._workers_lock: + if self._workers: + _LOG.warning("Ignoring reload_config while transcription workers are still running") + return + config_path = self._config_path + if config_path is None: + _LOG.warning("Ignoring reload_config because no config path is available") + return + try: + config = load_config(config_path) + except Exception as error: + _LOG.exception("Failed to reload config from %s: %s", config_path, error) + return + self._stop_listener_mode() + self._configure_runtime(config) + self._start_listener_mode() + self._set_recording_status(False, listener_mode=self._listener_mode) + _LOG.info("Config reloaded from %s", config_path) + + def _request_shutdown(self) -> None: + _LOG.info("Shutdown command received") + self._stop_event.set() + def _set_recording_status( self, is_recording: bool, @@ -555,6 +628,9 @@ def _set_recording_status( "state": "recording" if is_recording else "idle", "listener_mode": mode, } + command_server = getattr(self, "_command_server", None) + if command_server is not None and getattr(command_server, "port", 0): + payload["ipc_port"] = int(command_server.port) try: write_status(self._config.status_file, payload) except Exception: diff --git a/vibemouse/ipc/__init__.py b/vibemouse/ipc/__init__.py index a863974..1b17322 100644 --- a/vibemouse/ipc/__init__.py +++ b/vibemouse/ipc/__init__.py @@ -5,17 +5,26 @@ CommandMessage, EventMessage, Message, + binary_reader, + binary_writer, parse_message, + read_lpjson_frame, serialize_message, + write_lpjson_frame, ) -from vibemouse.ipc.server import IPCServer +from vibemouse.ipc.server import AgentCommandServer, IPCServer __all__ = [ + "AgentCommandServer", + "binary_reader", + "binary_writer", "CommandMessage", "EventMessage", "IPCClient", "IPCServer", "Message", "parse_message", + "read_lpjson_frame", "serialize_message", + "write_lpjson_frame", ] diff --git a/vibemouse/ipc/client.py b/vibemouse/ipc/client.py index f1e6a5a..179c0ab 100644 --- a/vibemouse/ipc/client.py +++ b/vibemouse/ipc/client.py @@ -1,29 +1,24 @@ -"""IPC client for connecting to agent via stdio (LPJSON).""" +"""IPC client for connecting to agent via stdio or other binary streams.""" from __future__ import annotations -import json import logging -import struct import sys from typing import Any, Callable from vibemouse.ipc.messages import ( - CommandMessage, - EventMessage, - Message, _decode_lpjson, - _encode_lpjson, + binary_reader, + binary_writer, + make_command_message, make_event_message, parse_message, + read_lpjson_frame, + write_lpjson_frame, ) _LOG = logging.getLogger(__name__) -_LENGTH_PREFIX_SIZE = 4 -_MAX_MESSAGE_SIZE = 1024 * 1024 - - class IPCClient: """ Client that sends events to agent and receives commands from agent @@ -40,36 +35,28 @@ def __init__( self._stdin = stdin if stdin is not None else sys.stdin self._stdout = stdout if stdout is not None else sys.stdout self._on_command = on_command - self._buffer = bytearray() self._running = False def send_event(self, event_name: str) -> None: """Send an event message to the agent.""" msg = make_event_message(event_name) - frame = _encode_lpjson(msg) - self._stdout.buffer.write(frame) - self._stdout.buffer.flush() + write_lpjson_frame(binary_writer(self._stdout), msg) + + def send_command(self, command_name: str) -> None: + """Send a command message to the connected peer.""" + msg = make_command_message(command_name) + write_lpjson_frame(binary_writer(self._stdout), msg) def run(self) -> None: """Run the client loop: read commands from stdin, dispatch to on_command.""" self._running = True + reader = binary_reader(self._stdin) while self._running: try: - prefix = self._stdin.buffer.read(_LENGTH_PREFIX_SIZE) - if len(prefix) == 0: - break - if len(prefix) < _LENGTH_PREFIX_SIZE: - _LOG.warning("Truncated length prefix") - break - length, = struct.unpack(" _MAX_MESSAGE_SIZE: - _LOG.error("Message size %d exceeds maximum", length) - break - body = self._stdin.buffer.read(length) - if len(body) < length: - _LOG.warning("Truncated payload") + frame = read_lpjson_frame(reader) + if frame is None: break - raw = json.loads(body.decode("utf-8")) + raw = _decode_lpjson(frame) msg = parse_message(raw) if msg.get("type") == "command": cmd = msg.get("command", "") diff --git a/vibemouse/ipc/messages.py b/vibemouse/ipc/messages.py index 41f27a5..cc8aa9b 100644 --- a/vibemouse/ipc/messages.py +++ b/vibemouse/ipc/messages.py @@ -68,6 +68,16 @@ def write_lpjson_frame(stream: Any, payload: dict[str, Any]) -> None: stream.flush() +def binary_reader(stream: Any) -> Any: + """Return a binary-capable reader for text or binary streams.""" + return getattr(stream, "buffer", stream) + + +def binary_writer(stream: Any) -> Any: + """Return a binary-capable writer for text or binary streams.""" + return getattr(stream, "buffer", stream) + + # --- Message types --- EventMessage = dict[str, Any] # {"type":"event","event":"mouse.side_front.press"} diff --git a/vibemouse/ipc/server.py b/vibemouse/ipc/server.py index c05335e..a0fe50c 100644 --- a/vibemouse/ipc/server.py +++ b/vibemouse/ipc/server.py @@ -1,28 +1,23 @@ -"""IPC server for agent to receive events from listener child via stdio (LPJSON).""" +"""IPC servers for stream and loopback command transports.""" from __future__ import annotations -import json import logging -import struct +import socket import threading from typing import Any, Callable from vibemouse.ipc.messages import ( - EventMessage, - Message, _decode_lpjson, - _encode_lpjson, + binary_writer, make_command_message, parse_message, + read_lpjson_frame, + write_lpjson_frame, ) _LOG = logging.getLogger(__name__) -_LENGTH_PREFIX_SIZE = 4 -_MAX_MESSAGE_SIZE = 1024 * 1024 - - class IPCServer: """ Server that reads events from a listener child's stdout and optionally @@ -34,11 +29,15 @@ def __init__( *, reader: Any, writer: Any | None = None, - on_event: Callable[[str], None], + on_event: Callable[[str], None] | None = None, + on_command: Callable[[str], None] | None = None, ) -> None: + if on_event is None and on_command is None: + raise ValueError("IPCServer requires on_event or on_command callback") self._reader = reader self._writer = writer self._on_event = on_event + self._on_command = on_command self._running = False self._thread: threading.Thread | None = None @@ -61,37 +60,153 @@ def send_command(self, command_name: str) -> None: if self._writer is None: return msg = make_command_message(command_name) - frame = _encode_lpjson(msg) try: - self._writer.write(frame) - self._writer.flush() + write_lpjson_frame(binary_writer(self._writer), msg) except Exception as error: _LOG.warning("Failed to send command to listener: %s", error) def _run(self) -> None: while self._running: try: - prefix = self._reader.read(_LENGTH_PREFIX_SIZE) - if len(prefix) == 0: - break - if len(prefix) < _LENGTH_PREFIX_SIZE: - _LOG.warning("Truncated length prefix from listener") - break - length, = struct.unpack(" _MAX_MESSAGE_SIZE: - _LOG.error("Message size %d exceeds maximum", length) - break - body = self._reader.read(length) - if len(body) < length: - _LOG.warning("Truncated payload from listener") + frame = read_lpjson_frame(self._reader) + if frame is None: break - raw = json.loads(body.decode("utf-8")) + raw = _decode_lpjson(frame) msg = parse_message(raw) if msg.get("type") == "event": event_name = msg.get("event", "") - self._on_event(event_name) + if self._on_event is not None: + self._on_event(event_name) + elif msg.get("type") == "command": + command_name = msg.get("command", "") + if self._on_command is not None: + self._on_command(command_name) except Exception as error: if self._running: _LOG.exception("IPC server error: %s", error) break self._running = False + + +class AgentCommandServer: + """Loopback-only command server for external clients driving the agent.""" + + def __init__( + self, + *, + on_command: Callable[[str], None], + host: str = "127.0.0.1", + port: int = 0, + ) -> None: + self._on_command = on_command + self._host = host + self._requested_port = port + self._listener: socket.socket | None = None + self._port = 0 + self._running = False + self._accept_thread: threading.Thread | None = None + self._client_threads: set[threading.Thread] = set() + self._client_connections: set[socket.socket] = set() + self._clients_lock = threading.Lock() + + @property + def port(self) -> int: + return self._port + + def start(self) -> None: + if self._listener is not None: + return + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((self._host, self._requested_port)) + listener.listen() + listener.settimeout(0.2) + self._listener = listener + self._port = int(listener.getsockname()[1]) + self._running = True + self._accept_thread = threading.Thread(target=self._accept_loop, daemon=True) + self._accept_thread.start() + + def stop(self) -> None: + self._running = False + listener = self._listener + self._listener = None + if listener is not None: + try: + listener.close() + except OSError: + pass + with self._clients_lock: + connections = list(self._client_connections) + for conn in connections: + try: + conn.close() + except OSError: + pass + if self._accept_thread is not None: + self._accept_thread.join(timeout=2) + self._accept_thread = None + with self._clients_lock: + client_threads = list(self._client_threads) + for thread in client_threads: + thread.join(timeout=2) + with self._clients_lock: + self._client_threads.clear() + self._client_connections.clear() + self._port = 0 + + def _accept_loop(self) -> None: + listener = self._listener + if listener is None: + return + while self._running: + try: + conn, _ = listener.accept() + except socket.timeout: + continue + except OSError as error: + if self._running: + _LOG.warning("Command server accept failed: %s", error) + break + with self._clients_lock: + self._client_connections.add(conn) + thread = threading.Thread( + target=self._serve_client, + args=(conn,), + daemon=True, + ) + with self._clients_lock: + self._client_threads.add(thread) + thread.start() + self._running = False + + def _serve_client(self, conn: socket.socket) -> None: + stream = conn.makefile("rwb") + current = threading.current_thread() + try: + while self._running: + frame = read_lpjson_frame(stream) + if frame is None: + break + raw = _decode_lpjson(frame) + msg = parse_message(raw) + if msg.get("type") != "command": + _LOG.debug("Ignoring non-command message on command server") + continue + command_name = msg.get("command", "") + self._on_command(command_name) + except Exception as error: + if self._running: + _LOG.exception("Command server client error: %s", error) + finally: + try: + stream.close() + except OSError: + pass + try: + conn.close() + except OSError: + pass + with self._clients_lock: + self._client_connections.discard(conn) + self._client_threads.discard(current)