android-emu-agent 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. android_emu_agent/__init__.py +3 -0
  2. android_emu_agent/actions/__init__.py +1 -0
  3. android_emu_agent/actions/executor.py +288 -0
  4. android_emu_agent/actions/selector.py +122 -0
  5. android_emu_agent/actions/wait.py +193 -0
  6. android_emu_agent/artifacts/__init__.py +1 -0
  7. android_emu_agent/artifacts/manager.py +125 -0
  8. android_emu_agent/artifacts/py.typed +0 -0
  9. android_emu_agent/cli/__init__.py +1 -0
  10. android_emu_agent/cli/commands/__init__.py +1 -0
  11. android_emu_agent/cli/commands/action.py +158 -0
  12. android_emu_agent/cli/commands/app_cmd.py +95 -0
  13. android_emu_agent/cli/commands/artifact.py +81 -0
  14. android_emu_agent/cli/commands/daemon.py +62 -0
  15. android_emu_agent/cli/commands/device.py +122 -0
  16. android_emu_agent/cli/commands/emulator.py +46 -0
  17. android_emu_agent/cli/commands/file.py +139 -0
  18. android_emu_agent/cli/commands/reliability.py +310 -0
  19. android_emu_agent/cli/commands/session.py +65 -0
  20. android_emu_agent/cli/commands/ui.py +112 -0
  21. android_emu_agent/cli/commands/wait.py +132 -0
  22. android_emu_agent/cli/daemon_client.py +185 -0
  23. android_emu_agent/cli/main.py +52 -0
  24. android_emu_agent/cli/utils.py +171 -0
  25. android_emu_agent/daemon/__init__.py +1 -0
  26. android_emu_agent/daemon/core.py +62 -0
  27. android_emu_agent/daemon/health.py +177 -0
  28. android_emu_agent/daemon/models.py +244 -0
  29. android_emu_agent/daemon/server.py +1644 -0
  30. android_emu_agent/db/__init__.py +1 -0
  31. android_emu_agent/db/models.py +229 -0
  32. android_emu_agent/device/__init__.py +1 -0
  33. android_emu_agent/device/manager.py +522 -0
  34. android_emu_agent/device/session.py +129 -0
  35. android_emu_agent/errors.py +232 -0
  36. android_emu_agent/files/__init__.py +1 -0
  37. android_emu_agent/files/manager.py +311 -0
  38. android_emu_agent/py.typed +0 -0
  39. android_emu_agent/reliability/__init__.py +1 -0
  40. android_emu_agent/reliability/manager.py +244 -0
  41. android_emu_agent/ui/__init__.py +1 -0
  42. android_emu_agent/ui/context.py +169 -0
  43. android_emu_agent/ui/ref_resolver.py +149 -0
  44. android_emu_agent/ui/snapshotter.py +236 -0
  45. android_emu_agent/validation.py +59 -0
  46. android_emu_agent-0.1.3.dist-info/METADATA +375 -0
  47. android_emu_agent-0.1.3.dist-info/RECORD +50 -0
  48. android_emu_agent-0.1.3.dist-info/WHEEL +4 -0
  49. android_emu_agent-0.1.3.dist-info/entry_points.txt +2 -0
  50. android_emu_agent-0.1.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,132 @@
1
+ """Wait/synchronization CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from android_emu_agent.cli.daemon_client import DaemonClient
8
+ from android_emu_agent.cli.utils import handle_response
9
+
10
+ app = typer.Typer(help="Wait/synchronization commands")
11
+
12
+
13
+ def _build_selector(
14
+ text: str | None, resource_id: str | None, desc: str | None
15
+ ) -> dict[str, str] | None:
16
+ selector: dict[str, str] = {}
17
+ if text:
18
+ selector["text"] = text
19
+ if resource_id:
20
+ selector["resourceId"] = resource_id
21
+ if desc:
22
+ selector["description"] = desc
23
+ return selector or None
24
+
25
+
26
+ @app.command("idle")
27
+ def wait_idle(
28
+ session_id: str = typer.Argument(..., help="Session ID"),
29
+ timeout_ms: int | None = typer.Option(None, "--timeout-ms", help="Timeout in ms"),
30
+ json_output: bool = typer.Option(False, "--json", help="Output JSON"),
31
+ ) -> None:
32
+ """Wait for UI idle."""
33
+ client = DaemonClient()
34
+ resp = client.request(
35
+ "POST",
36
+ "/wait/idle",
37
+ json_body={"session_id": session_id, "timeout_ms": timeout_ms},
38
+ )
39
+ client.close()
40
+ handle_response(resp, json_output=json_output)
41
+
42
+
43
+ @app.command("activity")
44
+ def wait_activity(
45
+ session_id: str = typer.Argument(..., help="Session ID"),
46
+ activity: str = typer.Argument(..., help="Activity substring"),
47
+ timeout_ms: int | None = typer.Option(None, "--timeout-ms", help="Timeout in ms"),
48
+ json_output: bool = typer.Option(False, "--json", help="Output JSON"),
49
+ ) -> None:
50
+ """Wait for an activity to appear."""
51
+ client = DaemonClient()
52
+ resp = client.request(
53
+ "POST",
54
+ "/wait/activity",
55
+ json_body={"session_id": session_id, "activity": activity, "timeout_ms": timeout_ms},
56
+ )
57
+ client.close()
58
+ handle_response(resp, json_output=json_output)
59
+
60
+
61
+ @app.command("text")
62
+ def wait_text(
63
+ session_id: str = typer.Argument(..., help="Session ID"),
64
+ text: str = typer.Argument(..., help="Text to wait for"),
65
+ timeout_ms: int | None = typer.Option(None, "--timeout-ms", help="Timeout in ms"),
66
+ json_output: bool = typer.Option(False, "--json", help="Output JSON"),
67
+ ) -> None:
68
+ """Wait for text to appear."""
69
+ client = DaemonClient()
70
+ resp = client.request(
71
+ "POST",
72
+ "/wait/text",
73
+ json_body={"session_id": session_id, "text": text, "timeout_ms": timeout_ms},
74
+ )
75
+ client.close()
76
+ handle_response(resp, json_output=json_output)
77
+
78
+
79
+ @app.command("exists")
80
+ def wait_exists(
81
+ session_id: str = typer.Argument(..., help="Session ID"),
82
+ ref: str | None = typer.Option(None, "--ref", help="Element ref (@a1)"),
83
+ text: str | None = typer.Option(None, "--text", help="Text selector"),
84
+ resource_id: str | None = typer.Option(None, "--id", help="Resource ID selector"),
85
+ desc: str | None = typer.Option(None, "--desc", help="Content-desc selector"),
86
+ timeout_ms: int | None = typer.Option(None, "--timeout-ms", help="Timeout in ms"),
87
+ json_output: bool = typer.Option(False, "--json", help="Output JSON"),
88
+ ) -> None:
89
+ """Wait for element to exist."""
90
+ selector = _build_selector(text, resource_id, desc)
91
+
92
+ client = DaemonClient()
93
+ resp = client.request(
94
+ "POST",
95
+ "/wait/exists",
96
+ json_body={
97
+ "session_id": session_id,
98
+ "ref": ref,
99
+ "selector": selector,
100
+ "timeout_ms": timeout_ms,
101
+ },
102
+ )
103
+ client.close()
104
+ handle_response(resp, json_output=json_output)
105
+
106
+
107
+ @app.command("gone")
108
+ def wait_gone(
109
+ session_id: str = typer.Argument(..., help="Session ID"),
110
+ ref: str | None = typer.Option(None, "--ref", help="Element ref (@a1)"),
111
+ text: str | None = typer.Option(None, "--text", help="Text selector"),
112
+ resource_id: str | None = typer.Option(None, "--id", help="Resource ID selector"),
113
+ desc: str | None = typer.Option(None, "--desc", help="Content-desc selector"),
114
+ timeout_ms: int | None = typer.Option(None, "--timeout-ms", help="Timeout in ms"),
115
+ json_output: bool = typer.Option(False, "--json", help="Output JSON"),
116
+ ) -> None:
117
+ """Wait for element to disappear."""
118
+ selector = _build_selector(text, resource_id, desc)
119
+
120
+ client = DaemonClient()
121
+ resp = client.request(
122
+ "POST",
123
+ "/wait/gone",
124
+ json_body={
125
+ "session_id": session_id,
126
+ "ref": ref,
127
+ "selector": selector,
128
+ "timeout_ms": timeout_ms,
129
+ },
130
+ )
131
+ client.close()
132
+ handle_response(resp, json_output=json_output)
@@ -0,0 +1,185 @@
1
+ """Daemon control and HTTP client for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ STATE_DIR = Path.home() / ".android-emu-agent"
17
+ SOCKET_PATH = Path("/tmp/android-emu-agent.sock")
18
+ PID_FILE = STATE_DIR / "daemon.pid"
19
+ LOG_FILE = STATE_DIR / "daemon.log"
20
+ BASE_URL = "http://android-emu-agent"
21
+
22
+
23
+ class DaemonController:
24
+ """Start/stop/status for the daemon process."""
25
+
26
+ def __init__(self, socket_path: Path = SOCKET_PATH) -> None:
27
+ self.socket_path = socket_path
28
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
29
+
30
+ def _pid_running(self, pid: int) -> bool:
31
+ try:
32
+ os.kill(pid, 0)
33
+ except OSError:
34
+ return False
35
+ return True
36
+
37
+ def _read_pid(self) -> int | None:
38
+ if not PID_FILE.exists():
39
+ return None
40
+ try:
41
+ return int(PID_FILE.read_text().strip())
42
+ except ValueError:
43
+ return None
44
+
45
+ def _socket_healthy(self) -> bool:
46
+ if not self.socket_path.exists():
47
+ return False
48
+ client = httpx.Client(
49
+ transport=httpx.HTTPTransport(uds=str(self.socket_path)),
50
+ base_url=BASE_URL,
51
+ timeout=1.0,
52
+ )
53
+ try:
54
+ resp = client.get("/health")
55
+ return resp.status_code == 200
56
+ except httpx.HTTPError:
57
+ return False
58
+ finally:
59
+ client.close()
60
+
61
+ def health(self) -> bool:
62
+ """Return True if the daemon socket responds to /health."""
63
+ return self._socket_healthy()
64
+
65
+ def start(self) -> int:
66
+ """Start the daemon; returns PID, or -1 if already running but PID unknown."""
67
+ pid = self._read_pid()
68
+ if pid and self._pid_running(pid):
69
+ return pid
70
+ if pid:
71
+ PID_FILE.unlink(missing_ok=True)
72
+ if self._socket_healthy():
73
+ return -1
74
+
75
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
76
+ args = [
77
+ sys.executable,
78
+ "-m",
79
+ "uvicorn",
80
+ "android_emu_agent.daemon.server:app",
81
+ "--uds",
82
+ str(self.socket_path),
83
+ "--log-level",
84
+ "info",
85
+ ]
86
+ with LOG_FILE.open("a", encoding="utf-8") as log_handle:
87
+ proc = subprocess.Popen(
88
+ args,
89
+ stdout=log_handle,
90
+ stderr=log_handle,
91
+ start_new_session=True,
92
+ )
93
+ PID_FILE.write_text(str(proc.pid))
94
+ return proc.pid
95
+
96
+ def stop(self) -> bool:
97
+ """Stop the daemon if running."""
98
+ pid = self._read_pid()
99
+ if not pid:
100
+ return False
101
+ if not self._pid_running(pid):
102
+ PID_FILE.unlink(missing_ok=True)
103
+ return False
104
+
105
+ os.kill(pid, signal.SIGTERM)
106
+ for _ in range(20):
107
+ if not self._pid_running(pid):
108
+ PID_FILE.unlink(missing_ok=True)
109
+ return True
110
+ time.sleep(0.1)
111
+ return False
112
+
113
+ def status(self) -> dict[str, Any]:
114
+ """Return daemon status summary."""
115
+ pid = self._read_pid()
116
+ return {
117
+ "pid": pid,
118
+ "pid_running": self._pid_running(pid) if pid else False,
119
+ "socket": str(self.socket_path),
120
+ "socket_exists": self.socket_path.exists(),
121
+ }
122
+
123
+
124
+ class DaemonClient:
125
+ """HTTP client using Unix Domain Socket transport."""
126
+
127
+ def __init__(
128
+ self,
129
+ socket_path: Path = SOCKET_PATH,
130
+ *,
131
+ auto_start: bool = True,
132
+ timeout: float = 10.0,
133
+ ) -> None:
134
+ self.socket_path = socket_path
135
+ self.auto_start = auto_start
136
+ self.controller = DaemonController(socket_path)
137
+ self._client = httpx.Client(
138
+ transport=httpx.HTTPTransport(uds=str(self.socket_path)),
139
+ base_url=BASE_URL,
140
+ timeout=timeout,
141
+ )
142
+
143
+ def close(self) -> None:
144
+ self._client.close()
145
+
146
+ def request(
147
+ self, method: str, path: str, json_body: dict[str, Any] | None = None
148
+ ) -> httpx.Response:
149
+ if self.auto_start:
150
+ self._ensure_ready()
151
+
152
+ try:
153
+ return self._client.request(method, path, json=json_body)
154
+ except httpx.TransportError:
155
+ if not self.auto_start:
156
+ raise
157
+ self.controller.start()
158
+ self._wait_for_health()
159
+ return self._client.request(method, path, json=json_body)
160
+
161
+ def _ensure_ready(self) -> None:
162
+ try:
163
+ resp = self._client.get("/health")
164
+ if resp.status_code == 200:
165
+ return
166
+ except httpx.TransportError:
167
+ pass
168
+ self.controller.start()
169
+ self._wait_for_health()
170
+
171
+ def _wait_for_health(self) -> None:
172
+ deadline = time.time() + 5
173
+ while time.time() < deadline:
174
+ try:
175
+ resp = self._client.get("/health")
176
+ if resp.status_code == 200:
177
+ return
178
+ except httpx.TransportError:
179
+ pass
180
+ time.sleep(0.1)
181
+ raise RuntimeError("Daemon did not become healthy in time")
182
+
183
+
184
+ def format_json(data: Any) -> str:
185
+ return json.dumps(data, indent=2, ensure_ascii=True)
@@ -0,0 +1,52 @@
1
+ """CLI entry point using Typer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from android_emu_agent.cli.commands import (
8
+ action,
9
+ app_cmd,
10
+ artifact,
11
+ daemon,
12
+ device,
13
+ emulator,
14
+ reliability,
15
+ session,
16
+ ui,
17
+ wait,
18
+ )
19
+ from android_emu_agent.cli.commands import (
20
+ file as file_commands,
21
+ )
22
+
23
+ app = typer.Typer(
24
+ name="android-emu-agent",
25
+ help="LLM-driven Android UI control system",
26
+ no_args_is_help=True,
27
+ )
28
+
29
+
30
+ @app.command()
31
+ def version() -> None:
32
+ """Show version information."""
33
+ from android_emu_agent import __version__
34
+
35
+ typer.echo(f"android-emu-agent v{__version__}")
36
+
37
+
38
+ app.add_typer(daemon.app, name="daemon")
39
+ app.add_typer(device.app, name="device")
40
+ app.add_typer(session.app, name="session")
41
+ app.add_typer(ui.app, name="ui")
42
+ app.add_typer(action.app, name="action")
43
+ app.add_typer(wait.app, name="wait")
44
+ app.add_typer(app_cmd.app, name="app")
45
+ app.add_typer(artifact.app, name="artifact")
46
+ app.add_typer(emulator.app, name="emulator")
47
+ app.add_typer(reliability.app, name="reliability")
48
+ app.add_typer(file_commands.app, name="file")
49
+
50
+
51
+ if __name__ == "__main__":
52
+ app()
@@ -0,0 +1,171 @@
1
+ """Shared CLI helpers and constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+
9
+ import typer
10
+
11
+ from android_emu_agent.cli.daemon_client import format_json
12
+
13
+ RELIABILITY_TIMEOUT = 60.0
14
+ RELIABILITY_BUGREPORT_TIMEOUT = 300.0
15
+ RELIABILITY_PULL_TIMEOUT = 180.0
16
+ RELIABILITY_DUMPHEAP_TIMEOUT = 180.0
17
+ FILE_TRANSFER_TIMEOUT = 120.0
18
+ FILE_APP_TRANSFER_TIMEOUT = 180.0
19
+
20
+
21
+ def _parse_response_json(resp: Any) -> dict[str, Any]:
22
+ try:
23
+ return cast(dict[str, Any], resp.json())
24
+ except Exception as exc: # pragma: no cover - defensive
25
+ typer.echo("Failed to parse response")
26
+ raise typer.Exit(code=1) from exc
27
+
28
+
29
+ def _maybe_render_error(data: dict[str, Any]) -> None:
30
+ if not (isinstance(data, dict) and data.get("error")):
31
+ return
32
+ error = data["error"]
33
+ message = f"{error.get('code')}: {error.get('message')}"
34
+ remediation = error.get("remediation")
35
+ typer.echo(message)
36
+ if remediation:
37
+ typer.echo(f"Hint: {remediation}")
38
+ raise typer.Exit(code=1)
39
+
40
+
41
+ def _maybe_render_done(data: dict[str, Any]) -> bool:
42
+ if not (isinstance(data, dict) and data.get("status") == "done"):
43
+ return False
44
+ message = "✓ Done"
45
+ if "elapsed_ms" in data:
46
+ message += f" ({data['elapsed_ms']} ms)"
47
+ if "path" in data:
48
+ message += f" -> {data['path']}"
49
+ typer.echo(message)
50
+ return True
51
+
52
+
53
+ def _maybe_render_output(data: dict[str, Any]) -> bool:
54
+ if not (isinstance(data, dict) and "output" in data):
55
+ return False
56
+ output = data.get("output")
57
+ if output:
58
+ typer.echo(output)
59
+ return True
60
+ return False
61
+
62
+
63
+ def handle_response(resp: Any, json_output: bool = False) -> None:
64
+ data = _parse_response_json(resp)
65
+ if json_output:
66
+ typer.echo(format_json(data))
67
+ return
68
+
69
+ _maybe_render_error(data)
70
+ if _maybe_render_done(data):
71
+ return
72
+ typer.echo(format_json(data))
73
+
74
+
75
+ def handle_output_response(resp: Any, json_output: bool = False) -> None:
76
+ data = _parse_response_json(resp)
77
+ if json_output:
78
+ typer.echo(format_json(data))
79
+ return
80
+
81
+ _maybe_render_error(data)
82
+ if _maybe_render_output(data):
83
+ return
84
+ if _maybe_render_done(data):
85
+ return
86
+ typer.echo(format_json(data))
87
+
88
+
89
+ def handle_response_with_pull(
90
+ resp: Any,
91
+ *,
92
+ json_output: bool = False,
93
+ pull: bool = False,
94
+ output: str | None = None,
95
+ ) -> None:
96
+ data = _parse_response_json(resp)
97
+ pulled_path: Path | None = None
98
+
99
+ if pull and not data.get("error"):
100
+ try:
101
+ path = data.get("path")
102
+ if not path:
103
+ raise typer.BadParameter("Response missing path; cannot pull artifact")
104
+ pulled_path = pull_artifact_path(path, output)
105
+ data["pulled_path"] = str(pulled_path)
106
+ except typer.BadParameter as exc:
107
+ typer.echo(f"Error: {exc}")
108
+ raise typer.Exit(code=1) from None
109
+
110
+ if json_output:
111
+ typer.echo(format_json(data))
112
+ return
113
+
114
+ _maybe_render_error(data)
115
+ if _maybe_render_done(data):
116
+ if pulled_path:
117
+ typer.echo(f"✓ Pulled -> {pulled_path}")
118
+ return
119
+ typer.echo(format_json(data))
120
+
121
+
122
+ def target_payload(device: str | None, session_id: str | None) -> dict[str, Any]:
123
+ if device and session_id:
124
+ raise typer.BadParameter("--device and --session are mutually exclusive")
125
+ if not device and not session_id:
126
+ raise typer.BadParameter("Provide --device or --session")
127
+ if device:
128
+ return {"serial": device}
129
+ assert session_id is not None
130
+ return {"session_id": session_id}
131
+
132
+
133
+ def resolve_session_id(session_arg: str | None, session_opt: str | None) -> str | None:
134
+ if session_arg and session_opt:
135
+ raise typer.BadParameter("Provide session ID once (argument or --session)")
136
+ return session_arg or session_opt
137
+
138
+
139
+ def pull_artifact_path(path_str: str, output: str | None = None) -> Path:
140
+ src = Path(path_str).expanduser()
141
+ if not src.exists():
142
+ raise typer.BadParameter(f"Artifact not found: {src}")
143
+
144
+ if output is None:
145
+ dest = Path.cwd() / src.name
146
+ else:
147
+ output_path = Path(output).expanduser()
148
+ output_str = str(output)
149
+ if output_path.exists() and output_path.is_dir():
150
+ dest = output_path / src.name
151
+ elif output_str.endswith(("/", "\\")):
152
+ output_path.mkdir(parents=True, exist_ok=True)
153
+ dest = output_path / src.name
154
+ else:
155
+ dest = output_path
156
+ dest.parent.mkdir(parents=True, exist_ok=True)
157
+
158
+ if src.resolve() == dest.resolve():
159
+ return dest
160
+
161
+ dest.parent.mkdir(parents=True, exist_ok=True)
162
+ shutil.copy2(src, dest)
163
+ return dest
164
+
165
+
166
+ def require_target(device: str | None, session_id: str | None) -> dict[str, Any]:
167
+ try:
168
+ return target_payload(device, session_id)
169
+ except typer.BadParameter as exc:
170
+ typer.echo(f"Error: {exc}")
171
+ raise typer.Exit(code=1) from None
@@ -0,0 +1 @@
1
+ """Daemon - Core server managing device connections and sessions."""
@@ -0,0 +1,62 @@
1
+ """Daemon core - lifecycle, request routing, state management."""
2
+
3
+ import structlog
4
+
5
+ from android_emu_agent.actions.executor import ActionExecutor
6
+ from android_emu_agent.actions.wait import WaitEngine
7
+ from android_emu_agent.artifacts.manager import ArtifactManager
8
+ from android_emu_agent.daemon.health import HealthMonitor
9
+ from android_emu_agent.db.models import Database
10
+ from android_emu_agent.device.manager import DeviceManager
11
+ from android_emu_agent.device.session import SessionManager
12
+ from android_emu_agent.files.manager import FileManager
13
+ from android_emu_agent.reliability.manager import ReliabilityManager
14
+ from android_emu_agent.ui.context import ContextResolver
15
+ from android_emu_agent.ui.ref_resolver import RefResolver
16
+ from android_emu_agent.ui.snapshotter import UISnapshotter
17
+
18
+ logger = structlog.get_logger()
19
+
20
+
21
+ class DaemonCore:
22
+ """Central daemon coordinator managing all subsystems."""
23
+
24
+ def __init__(self) -> None:
25
+ self.database = Database()
26
+ self.device_manager = DeviceManager()
27
+ self.session_manager = SessionManager(self.database)
28
+ self.snapshotter = UISnapshotter()
29
+ self.ref_resolver = RefResolver()
30
+ self.action_executor = ActionExecutor()
31
+ self.wait_engine = WaitEngine()
32
+ self.artifact_manager = ArtifactManager()
33
+ self.file_manager = FileManager()
34
+ self.reliability_manager = ReliabilityManager()
35
+ self.context_resolver = ContextResolver()
36
+ self.health_monitor = HealthMonitor(self.device_manager, self.session_manager)
37
+ self._running = False
38
+
39
+ async def start(self) -> None:
40
+ """Initialize all subsystems."""
41
+ logger.info("daemon_core_starting")
42
+ await self.database.connect()
43
+ await self.device_manager.start()
44
+ await self.session_manager.start()
45
+ await self.health_monitor.start()
46
+ self._running = True
47
+ logger.info("daemon_core_started")
48
+
49
+ async def stop(self) -> None:
50
+ """Gracefully shutdown all subsystems."""
51
+ logger.info("daemon_core_stopping")
52
+ self._running = False
53
+ await self.health_monitor.stop()
54
+ await self.session_manager.stop()
55
+ await self.device_manager.stop()
56
+ await self.database.disconnect()
57
+ logger.info("daemon_core_stopped")
58
+
59
+ @property
60
+ def is_running(self) -> bool:
61
+ """Check if daemon is running."""
62
+ return self._running