agentdodont 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ """Agent Do/Don't: a rules engine between an AI agent and its tools, built assuming the model can be hijacked."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,297 @@
1
+ """CLI: python -m agentdodont run "turn on dark mode"
2
+ python -m agentdodont doctor
3
+ python -m agentdodont log logs/<session>
4
+ python -m agentdodont relay # API key stays here; the phone app talks to this
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ import sys
12
+ import time
13
+
14
+ from . import ui
15
+ from .adb import AdbError, Device
16
+
17
+
18
+ def cmd_doctor(args) -> int:
19
+ dev = Device(args.serial)
20
+ ok = True
21
+
22
+ def check(label, fn):
23
+ nonlocal ok
24
+ try:
25
+ val = fn()
26
+ print(f" ok {label}: {val}")
27
+ return val
28
+ except Exception as e: # noqa: BLE001 - diagnostic output
29
+ ok = False
30
+ print(f" FAIL {label}: {e}")
31
+ return None
32
+
33
+ print(f"adb: {dev.adb}")
34
+ devices = check("devices", dev.list_devices)
35
+ if devices is not None:
36
+ ready = [s for s, st in devices if st == "device"]
37
+ for s, st in devices:
38
+ if st == "unauthorized":
39
+ print(f" !! {s} is unauthorized: unlock the phone and accept the USB debugging prompt")
40
+ if not ready:
41
+ print(" FAIL no authorized device. Enable Developer options + USB debugging and plug in via USB.")
42
+ return 1
43
+ if len(ready) > 1 and not args.serial:
44
+ print(f" FAIL several devices connected ({', '.join(ready)}); pass --serial")
45
+ return 1
46
+ check("model", lambda: f"{dev.getprop('ro.product.manufacturer')} {dev.getprop('ro.product.model')}")
47
+ check("android", lambda: f"{dev.getprop('ro.build.version.release')} (SDK {dev.getprop('ro.build.version.sdk')}), One UI {dev.getprop('ro.build.version.oneui') or '?'}")
48
+ check("screen size", dev.screen_size)
49
+ check("packages", lambda: f"{len(dev.packages())} installed")
50
+ t0 = time.time()
51
+ xml = check("uiautomator dump", lambda: dev.dump_ui())
52
+ if xml:
53
+ s = ui.parse(xml, dev.screen_size())
54
+ print(f" {len(s.elements)} elements in {time.time() - t0:.1f}s, foreground {s.package}")
55
+ if args.verbose:
56
+ print(s.render())
57
+ check("screencap", lambda: f"{len(dev.screencap()) // 1024} KB PNG")
58
+ has_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN"))
59
+ print(f" {'ok ' if has_key else 'warn'} Anthropic credentials in env: {has_key}")
60
+ try:
61
+ import PIL # noqa: F401
62
+ print(" ok Pillow installed (screenshots get downscaled)")
63
+ except ImportError:
64
+ print(" warn Pillow not installed: screenshots are sent full size (pip install pillow)")
65
+ print("all good" if ok else "some checks failed")
66
+ return 0 if ok else 1
67
+
68
+
69
+ def cmd_screen(args) -> int:
70
+ dev = Device(args.serial)
71
+ print(ui.parse(dev.dump_ui(), dev.screen_size()).render())
72
+ return 0
73
+
74
+
75
+ def cmd_run(args) -> int:
76
+ from .agent import DEFAULT_MODEL, Agent
77
+ from .audit import AuditLog
78
+ from .policy import Policy
79
+
80
+ dev = Device(args.serial)
81
+ if not dev.serial: # record which phone the session ran on
82
+ ready = [s for s, st in dev.list_devices() if st == "device"]
83
+ if len(ready) == 1:
84
+ dev.serial = ready[0]
85
+ policy = Policy.load(args.policy, ask_all=args.ask_all)
86
+ log = AuditLog(args.log_dir, save_screens=args.save_screens)
87
+ agent = Agent(dev, policy, log, model=args.model or DEFAULT_MODEL, effort=args.effort,
88
+ max_steps=args.max_steps, dry_run=args.dry_run, fallbacks=not args.no_fallbacks)
89
+ print(f"task: {args.task}\nlog: {log.dir}\n")
90
+ try:
91
+ result = agent.run(args.task)
92
+ except KeyboardInterrupt:
93
+ log.event("stop", step=agent.step, reason="interrupted by user")
94
+ print("\ninterrupted")
95
+ return 130
96
+ except AdbError as e:
97
+ log.event("stop", step=agent.step, reason=f"adb error: {e}")
98
+ print(f"adb error: {e}")
99
+ return 2
100
+ finally:
101
+ log.close()
102
+ u = agent.usage
103
+ print(f"\nsteps: {result.steps} tokens in {u['input']} (cache read {u['cache_read']}, write {u['cache_write']}) / out {u['output']}")
104
+ print(f"log: {result.log_dir} (python -m agentdodont log {result.log_dir})")
105
+ return 0 if result.success else 1
106
+
107
+
108
+ def cmd_suite(args) -> int:
109
+ from .agent import DEFAULT_MODEL
110
+ from .suite import TASKS, format_report, run_suite
111
+
112
+ if args.list:
113
+ for t in TASKS:
114
+ print(f"{t.name:<24} {t.prompt if isinstance(t.prompt, str) else '(generated prompt)'}")
115
+ return 0
116
+ dev = Device(args.serial)
117
+ if not dev.serial:
118
+ ready = [s for s, st in dev.list_devices() if st == "device"]
119
+ if len(ready) == 1:
120
+ dev.serial = ready[0]
121
+ names = [n.strip() for n in args.tasks.split(",")] if args.tasks else None
122
+ report = run_suite(dev, args.model or DEFAULT_MODEL, args.effort, names, args.repeat)
123
+ print(format_report(report))
124
+ return 0 if all(r["passed"] is not False for r in report["rows"]) else 1
125
+
126
+
127
+ def cmd_log(args) -> int:
128
+ from pathlib import Path
129
+
130
+ from .audit import show
131
+
132
+ root = Path(args.log_dir)
133
+ sessions = sorted(p for p in root.glob("*") if (p / "actions.jsonl").exists()) if root.exists() else []
134
+ if args.list:
135
+ for p in sessions:
136
+ print(p)
137
+ return 0
138
+ if args.session in (None, "latest"):
139
+ if not sessions:
140
+ print(f"no sessions found in {root}")
141
+ return 1
142
+ target = sessions[-1]
143
+ else:
144
+ target = Path(args.session)
145
+ if not target.exists() and (root / args.session).exists():
146
+ target = root / args.session
147
+ if not target.exists():
148
+ print(f"no such session: {args.session}")
149
+ if sessions:
150
+ print("available sessions:\n " + "\n ".join(str(p) for p in sessions[-10:]))
151
+ return 1
152
+ print(f"session: {target}\n")
153
+ print(show(target))
154
+ return 0
155
+
156
+
157
+ def cmd_relay(args) -> int:
158
+ import threading
159
+ from pathlib import Path
160
+
161
+ from .pairing import DeviceRegistry, PairingSession, ensure_cert, lan_ip, pair_link, qr_ascii
162
+ from .relay import Relay, ensure_token, keep_phone_linked, make_server, make_tls_server, read_properties
163
+
164
+ state = Path(args.state_dir)
165
+ devices = DeviceRegistry(state / "devices.json")
166
+ if args.devices:
167
+ for d in devices.devices or [{"id": "-", "name": "no paired devices", "paired": "", "last_seen": ""}]:
168
+ print(f"{d['id']:<4} {d['name']:<24} paired {d['paired']} last seen {d['last_seen'] or 'never'}")
169
+ return 0
170
+ if args.revoke:
171
+ ok = devices.revoke(args.revoke)
172
+ print(f"revoked {args.revoke}" if ok else f"no device {args.revoke}")
173
+ return 0 if ok else 1
174
+
175
+ props_path = Path(args.properties)
176
+ api_key = os.environ.get("ANTHROPIC_API_KEY") or read_properties(props_path).get("anthropic.apiKey")
177
+ if not api_key:
178
+ print(f"no API key: set ANTHROPIC_API_KEY or put anthropic.apiKey=... in {props_path}")
179
+ return 1
180
+ token, created = ensure_token(props_path, rotate=args.new_token)
181
+ relay = Relay(api_key, token, daily_budget_usd=args.budget, state_dir=Path(args.log_dir), devices=devices)
182
+ server = make_server(relay, port=args.port)
183
+ tls = None
184
+ if args.lan or args.pair:
185
+ cert, key, fp = ensure_cert(state)
186
+ host = args.pair_host or f"{lan_ip()}:{args.lan_port}"
187
+ tls = make_tls_server(relay, "0.0.0.0", args.lan_port, cert, key)
188
+ threading.Thread(target=tls.serve_forever, daemon=True).start()
189
+ print(f"Wi-Fi relay (TLS) on {host} certificate {fp[:16]}… paired devices: {len(devices.devices)}")
190
+ if args.pair:
191
+ relay.pairing = PairingSession(fp)
192
+ link = pair_link(host, relay.pairing.code, fp)
193
+ print("\nScan with the phone's camera, or type in Agent Do/Don't: pair " + host + " " + relay.pairing.code)
194
+ print(qr_ascii(link))
195
+ print(f"code {relay.pairing.code} (works once, for 5 minutes) fingerprint {fp[:8]}-{fp[8:16]}\n")
196
+ print(f"relay on 127.0.0.1:{args.port} budget ${args.budget:.2f}/day (spent today ${relay.spent_today():.2f})")
197
+ print(f"log: {relay.log_path} (metadata only)")
198
+ if created:
199
+ print(f" new relay token generated (saved in {props_path}); the old one no longer works")
200
+ stop = threading.Event()
201
+ threading.Thread(target=keep_phone_linked, args=(Device(args.serial).adb, args.serial, args.port, token, stop),
202
+ daemon=True).start()
203
+ try:
204
+ server.serve_forever()
205
+ except KeyboardInterrupt:
206
+ print("\nrelay stopped")
207
+ finally:
208
+ stop.set()
209
+ server.server_close()
210
+ if tls:
211
+ tls.server_close()
212
+ return 0
213
+
214
+
215
+ def cmd_mcp_proxy(args) -> int:
216
+ from .mcp_proxy import main as proxy_main # imported here: the other commands don't need it
217
+ return proxy_main(args)
218
+
219
+
220
+ def main(argv: list[str] | None = None) -> int:
221
+ from . import __version__
222
+ p = argparse.ArgumentParser(prog="agentdodont", description="Agent Do/Don't: a rules engine between an AI agent "
223
+ "and its tools. mcp-proxy guards any MCP server; the other commands drive an Android phone.")
224
+ p.add_argument("--version", action="version", version=f"agentdodont {__version__}")
225
+ p.add_argument("--serial", help="adb device serial (when several are connected)")
226
+ sub = p.add_subparsers(dest="cmd", required=True)
227
+
228
+ r = sub.add_parser("run", help="run a task on the phone")
229
+ r.add_argument("task")
230
+ r.add_argument("--model", help="Claude model id (default claude-opus-5-5)")
231
+ r.add_argument("--effort", default="medium", choices=["low", "medium", "high", "xhigh", "max"])
232
+ r.add_argument("--max-steps", type=int, default=40)
233
+ r.add_argument("--policy", help="JSON file overriding policy defaults (levels, word lists, packages)")
234
+ r.add_argument("--ask-all", action="store_true", help="ask before every action")
235
+ r.add_argument("--dry-run", action="store_true", help="observe and decide, but never touch the phone")
236
+ r.add_argument("--save-screens", action="store_true", help="save every UI dump into the log dir")
237
+ r.add_argument("--log-dir", default="logs")
238
+ r.add_argument("--no-fallbacks", action="store_true", help="disable server-side refusal fallback")
239
+ r.set_defaults(fn=cmd_run)
240
+
241
+ d = sub.add_parser("doctor", help="check adb, device, and credentials")
242
+ d.add_argument("-v", "--verbose", action="store_true", help="also print the current screen")
243
+ d.set_defaults(fn=cmd_doctor)
244
+
245
+ st = sub.add_parser("suite", help="run the repeatable task suite with ground-truth checks")
246
+ st.add_argument("--model", help="Claude model id (default claude-opus-5-5)")
247
+ st.add_argument("--effort", default="medium", choices=["low", "medium", "high", "xhigh", "max"])
248
+ st.add_argument("--tasks", help="comma-separated task names (default: all)")
249
+ st.add_argument("--repeat", type=int, default=1)
250
+ st.add_argument("--list", action="store_true", help="list tasks and exit")
251
+ st.set_defaults(fn=cmd_suite)
252
+
253
+ rl = sub.add_parser("relay", help="hold the API key for the phone app (tunnelled over USB)")
254
+ rl.add_argument("--port", type=int, default=8787)
255
+ rl.add_argument("--budget", type=float, default=2.0, help="daily spend cap in USD (default 2)")
256
+ rl.add_argument("--properties", default="android/local.properties", help="where the key and token live")
257
+ rl.add_argument("--log-dir", default="logs")
258
+ rl.add_argument("--new-token", action="store_true", help="replace the phone token (no rebuild needed)")
259
+ rl.add_argument("--lan", action="store_true", help="also serve paired phones over Wi-Fi (TLS)")
260
+ rl.add_argument("--lan-port", type=int, default=8788)
261
+ rl.add_argument("--pair", action="store_true", help="show a QR code to pair a phone (implies --lan)")
262
+ rl.add_argument("--pair-host", help="address to put in the QR code (default: this laptop's LAN IP)")
263
+ rl.add_argument("--devices", action="store_true", help="list paired phones and exit")
264
+ rl.add_argument("--revoke", metavar="ID", help="unpair a phone (see --devices) and exit")
265
+ rl.add_argument("--state-dir", default=".relay", help="where the certificate and paired devices live")
266
+ rl.set_defaults(fn=cmd_relay)
267
+
268
+ s = sub.add_parser("screen",help="print the current screen as the model sees it")
269
+ s.set_defaults(fn=cmd_screen)
270
+
271
+ lg = sub.add_parser("log", help="show a session log as a 'why did you do that?' trace")
272
+ lg.add_argument("session", nargs="?", help="session folder, its name, or 'latest' (default: latest)")
273
+ lg.add_argument("--list", action="store_true", help="list all sessions")
274
+ lg.add_argument("--log-dir", default="logs")
275
+ lg.set_defaults(fn=cmd_log)
276
+
277
+ mp = sub.add_parser("mcp-proxy", help="put the rules engine between an MCP client and an MCP server")
278
+ mp.add_argument("--rules", help="JSON rules file (levels, allowed_sites, known_recipients, tools...)")
279
+ mp.add_argument("--name", help="the server's name in questions and the log (default: the command)")
280
+ mp.add_argument("--log", default=str(__import__("pathlib").Path.home() / ".agentdodont" / "mcp-audit.jsonl"),
281
+ help="audit log (JSONL)")
282
+ mp.add_argument("--session", help="share taint with other proxies in this session: default = every proxy started "
283
+ "by the same client; a name = every proxy given that name; none = don't share")
284
+ mp.add_argument("server", nargs=argparse.REMAINDER, help="-- then the MCP server command and its arguments")
285
+ mp.set_defaults(fn=cmd_mcp_proxy)
286
+
287
+ args = p.parse_args(argv)
288
+ for stream in (sys.stdout, sys.stderr): # Windows consoles default to cp1252; UI text is Unicode
289
+ try:
290
+ stream.reconfigure(encoding="utf-8", errors="replace")
291
+ except (AttributeError, ValueError):
292
+ pass
293
+ return args.fn(args)
294
+
295
+
296
+ if __name__ == "__main__":
297
+ sys.exit(main())
agentdodont/adb.py ADDED
@@ -0,0 +1,189 @@
1
+ """Thin wrapper around the adb binary: observe the screen and inject input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import time
9
+
10
+ # Common app names -> candidate packages (Samsung first, then AOSP/Google).
11
+ APP_ALIASES: dict[str, list[str]] = {
12
+ "settings": ["com.android.settings"],
13
+ "chrome": ["com.android.chrome"],
14
+ "browser": ["com.sec.android.app.sbrowser", "com.android.chrome"],
15
+ "internet": ["com.sec.android.app.sbrowser"],
16
+ "messages": ["com.samsung.android.messaging", "com.google.android.apps.messaging"],
17
+ "sms": ["com.samsung.android.messaging", "com.google.android.apps.messaging"],
18
+ "phone": ["com.samsung.android.dialer", "com.google.android.dialer"],
19
+ "dialer": ["com.samsung.android.dialer", "com.google.android.dialer"],
20
+ "contacts": ["com.samsung.android.app.contacts", "com.google.android.contacts"],
21
+ "camera": ["com.sec.android.app.camera"],
22
+ "gallery": ["com.sec.android.gallery3d", "com.google.android.apps.photos"],
23
+ "photos": ["com.google.android.apps.photos", "com.sec.android.gallery3d"],
24
+ "clock": ["com.sec.android.app.clockpackage", "com.google.android.deskclock"],
25
+ "alarm": ["com.sec.android.app.clockpackage", "com.google.android.deskclock"],
26
+ "calendar": ["com.samsung.android.calendar", "com.google.android.calendar"],
27
+ "calculator": ["com.sec.android.app.popupcalculator", "com.google.android.calculator"],
28
+ "notes": ["com.samsung.android.app.notes", "com.google.android.keep"],
29
+ "my files": ["com.sec.android.app.myfiles"],
30
+ "files": ["com.sec.android.app.myfiles", "com.google.android.apps.nbu.files"],
31
+ "play store": ["com.android.vending"],
32
+ "gmail": ["com.google.android.gm"],
33
+ "email": ["com.samsung.android.email.provider", "com.google.android.gm"],
34
+ "maps": ["com.google.android.apps.maps"],
35
+ "youtube": ["com.google.android.youtube"],
36
+ "google": ["com.google.android.googlequicksearchbox"],
37
+ }
38
+
39
+ # Characters the device shell would interpret inside `input text`.
40
+ _SHELL_SPECIAL = set("\\'\"`$&|;<>()*~#!?[]{}^")
41
+
42
+
43
+ class AdbError(RuntimeError):
44
+ pass
45
+
46
+
47
+ def escape_input_text(text: str) -> str:
48
+ """Escape text for `adb shell input text`. Only printable ASCII is supported."""
49
+ out = []
50
+ for ch in text:
51
+ if ord(ch) > 126 or (ord(ch) < 32):
52
+ raise AdbError(
53
+ f"`input text` cannot type {ch!r}; only printable ASCII on one line is supported "
54
+ "(install ADBKeyboard for Unicode — a later improvement)."
55
+ )
56
+ if ch == " ":
57
+ out.append("%s")
58
+ elif ch in _SHELL_SPECIAL:
59
+ out.append("\\" + ch)
60
+ else:
61
+ out.append(ch)
62
+ return "".join(out)
63
+
64
+
65
+ def resolve_app(name: str, installed: list[str]) -> tuple[str | None, list[str]]:
66
+ """Map a human app name to an installed package. Returns (package, candidates)."""
67
+ key = name.strip().lower()
68
+ if key in installed: # already a package name
69
+ return key, []
70
+ for pkg in APP_ALIASES.get(key, []):
71
+ if pkg in installed:
72
+ return pkg, []
73
+ tokens = [t for t in re.split(r"[^a-z0-9]+", key) if t]
74
+ if not tokens:
75
+ return None, []
76
+ hits = [p for p in installed if all(t in p.lower() for t in tokens)]
77
+ if len(hits) == 1:
78
+ return hits[0], []
79
+ return None, sorted(hits)[:15]
80
+
81
+
82
+ class Device:
83
+ def __init__(self, serial: str | None = None, adb_path: str | None = None):
84
+ self.adb = adb_path or shutil.which("adb") or "adb"
85
+ self.serial = serial
86
+ self._size: tuple[int, int] | None = None
87
+ self._packages: list[str] | None = None
88
+
89
+ # --- plumbing -----------------------------------------------------------
90
+ def _cmd(self, *args: str) -> list[str]:
91
+ base = [self.adb]
92
+ if self.serial:
93
+ base += ["-s", self.serial]
94
+ return base + list(args)
95
+
96
+ def run(self, *args: str, timeout: float = 30, binary: bool = False):
97
+ try:
98
+ proc = subprocess.run(self._cmd(*args), capture_output=True, timeout=timeout)
99
+ except FileNotFoundError as e:
100
+ raise AdbError(f"adb not found at {self.adb!r}") from e
101
+ except subprocess.TimeoutExpired as e:
102
+ raise AdbError(f"adb {' '.join(args)} timed out") from e
103
+ if proc.returncode != 0:
104
+ err = proc.stderr.decode(errors="replace").strip() or proc.stdout.decode(errors="replace").strip()
105
+ raise AdbError(f"adb {' '.join(args)} failed: {err}")
106
+ return proc.stdout if binary else proc.stdout.decode(errors="replace")
107
+
108
+ def shell(self, command: str, timeout: float = 30) -> str:
109
+ return self.run("shell", command, timeout=timeout)
110
+
111
+ # --- info ---------------------------------------------------------------
112
+ def list_devices(self) -> list[tuple[str, str]]:
113
+ out = self.run("devices")
114
+ rows = []
115
+ for line in out.splitlines()[1:]:
116
+ parts = line.split()
117
+ if len(parts) >= 2:
118
+ rows.append((parts[0], parts[1]))
119
+ return rows
120
+
121
+ def getprop(self, name: str) -> str:
122
+ return self.shell(f"getprop {name}").strip()
123
+
124
+ def screen_size(self) -> tuple[int, int]:
125
+ if self._size is None:
126
+ out = self.shell("wm size")
127
+ # "Physical size: 1080x2340" possibly followed by "Override size: ..."
128
+ matches = re.findall(r"(\d+)x(\d+)", out)
129
+ if not matches:
130
+ raise AdbError(f"cannot parse screen size from {out!r}")
131
+ w, h = matches[-1]
132
+ self._size = (int(w), int(h))
133
+ return self._size
134
+
135
+ def packages(self, refresh: bool = False) -> list[str]:
136
+ if self._packages is None or refresh:
137
+ out = self.shell("pm list packages")
138
+ self._packages = [l.split(":", 1)[1].strip() for l in out.splitlines() if l.startswith("package:")]
139
+ return self._packages
140
+
141
+ # --- observe ------------------------------------------------------------
142
+ def dump_ui(self, retries: int = 3) -> str:
143
+ last = ""
144
+ for attempt in range(retries):
145
+ out = self.shell("uiautomator dump /sdcard/aos_dump.xml", timeout=30)
146
+ if "dumped to" in out:
147
+ xml = self.run("exec-out", "cat /sdcard/aos_dump.xml")
148
+ if "<hierarchy" in xml:
149
+ return xml
150
+ last = out.strip()
151
+ time.sleep(0.7 * (attempt + 1)) # usually "could not get idle state" during animations
152
+ raise AdbError(f"uiautomator dump failed: {last}")
153
+
154
+ def screencap(self) -> bytes:
155
+ data = self.run("exec-out", "screencap -p", binary=True, timeout=30)
156
+ if not data.startswith(b"\x89PNG"):
157
+ raise AdbError("screencap did not return a PNG")
158
+ return data
159
+
160
+ # --- act ----------------------------------------------------------------
161
+ def tap(self, x: int, y: int) -> None:
162
+ self.shell(f"input tap {int(x)} {int(y)}")
163
+
164
+ def long_press(self, x: int, y: int, ms: int = 800) -> None:
165
+ self.shell(f"input swipe {int(x)} {int(y)} {int(x)} {int(y)} {ms}")
166
+
167
+ def swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 350) -> None:
168
+ self.shell(f"input swipe {int(x1)} {int(y1)} {int(x2)} {int(y2)} {ms}")
169
+
170
+ def keyevent(self, code: int | str) -> None:
171
+ self.shell(f"input keyevent {code}")
172
+
173
+ def type_text(self, text: str) -> None:
174
+ if text:
175
+ self.shell(f"input text {escape_input_text(text)}")
176
+
177
+ def clear_field(self) -> None:
178
+ # Ctrl+A then Delete (Android 13+ supports keycombination); fall back to many DELs.
179
+ try:
180
+ self.shell("input keycombination 113 29")
181
+ self.keyevent(67)
182
+ except AdbError:
183
+ self.keyevent("KEYCODE_MOVE_END")
184
+ self.shell("input keyevent " + " ".join(["67"] * 60))
185
+
186
+ def launch(self, package: str) -> None:
187
+ out = self.shell(f"monkey -p {package} -c android.intent.category.LAUNCHER 1")
188
+ if "No activities found" in out or "aborted" in out.lower():
189
+ raise AdbError(f"could not launch {package}: {out.strip()}")