tapwright 1.0.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.
tapwright/cli.py ADDED
@@ -0,0 +1,374 @@
1
+ """tapwright: let LLM agents test apps on your real iPhone, from Windows, Linux or macOS.
2
+
3
+ tapwright setup one time, phone on USB: pair, sign and install WebDriverAgent,
4
+ pair for network use. Signing: --asc-key-id/--asc-issuer-id/--asc-key-file
5
+ with --bundle-prefix (App Store Connect API key), or --p12/--profile
6
+ tapwright doctor check the setup and say what's missing
7
+ tapwright smoke --app BUNDLE_ID
8
+ open an app, read its first screen, save a screenshot, close it
9
+ tapwright probe [--host IP] [--watch]
10
+ does it work over the network? Learns the pairing port on the local
11
+ Wi-Fi; with --host (e.g. the phone's Tailscale IP) tries that address
12
+ tapwright serve [--phone ADDRESS] [--port N] [--keep-alive] [--new-token] [--stop]
13
+ the background process that owns the phone: the live view in your
14
+ browser, the agent tools and an HTTP API. Agent tools start it by
15
+ themselves; it stops after 30 minutes unused (--keep-alive: never)
16
+ tapwright view open the live view in your browser (starts the daemon if needed)
17
+ tapwright mcp [--screenshots DIR] the MCP server for agents (stdio); it talks to the daemon
18
+
19
+ Connection: USB when the phone is plugged in, else the local network; serve --phone dials an
20
+ address such as the phone's Tailscale IP (experimental).
21
+ """
22
+ import argparse
23
+ import asyncio
24
+ import logging
25
+ import socket
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+
30
+ from . import store
31
+
32
+
33
+ def ok(msg):
34
+ print(f" ok {msg}", flush=True)
35
+
36
+
37
+ def note(msg):
38
+ print(f" note {msg}", flush=True)
39
+
40
+
41
+ def fail(msg):
42
+ print(f" FAIL {msg}", flush=True)
43
+ return 1
44
+
45
+
46
+ def _daemon_owns_phone() -> bool:
47
+ from . import client
48
+ return client.healthy(store.read_serve_info()) is not None
49
+
50
+
51
+ DAEMON_RUNNING = "tapwright serve is running and owns the phone: stop it first with `tapwright serve --stop`"
52
+ INSTALL_WAIT = 180.0 # seconds the phone gets to install WebDriverAgent (it takes about 20)
53
+
54
+
55
+ # ---------- setup ----------
56
+
57
+ async def setup(a) -> int:
58
+ from pymobiledevice3 import usbmux
59
+ from pymobiledevice3.exceptions import RemotePairingCompletedError
60
+ from pymobiledevice3.lockdown import create_using_usbmux
61
+ from pymobiledevice3.remote.tunnel_service import RemotePairingLockdownService
62
+ from pymobiledevice3.services.installation_proxy import InstallationProxyService
63
+
64
+ from . import signing
65
+ if _daemon_owns_phone(): # its WebDriverAgent is running: iOS would hold the new one's install open
66
+ return fail(DAEMON_RUNNING)
67
+ print("1. The iPhone, on USB")
68
+ usb = [d for d in await usbmux.list_devices() if d.connection_type == "USB"]
69
+ if not usb:
70
+ return fail("no iPhone on USB. Plug it in and unlock it. (Windows: install Apple Devices from the "
71
+ "Microsoft Store; Linux: usbmuxd.)")
72
+ udid = a.udid or usb[0].serial
73
+ lockdown = await create_using_usbmux(serial=udid, autopair=True) # the phone asks "Trust This Computer?"
74
+ name = lockdown.all_values.get("DeviceName") or "iPhone"
75
+ ok(f"{name} ({lockdown.display_name or lockdown.product_type}, iOS {lockdown.product_version}) {udid}")
76
+
77
+ print("2. Developer Mode")
78
+ if not await lockdown.get_developer_mode_status():
79
+ from pymobiledevice3.services.amfi import AmfiService
80
+ await AmfiService(lockdown).reveal_developer_mode_option_in_ui()
81
+ return fail("Developer Mode is off. On the iPhone: Settings > Privacy & Security > Developer Mode, turn "
82
+ "it on, restart, confirm Turn On. Then run setup again.")
83
+ ok("on")
84
+
85
+ print("3. Signing identity")
86
+ if a.asc_key_id:
87
+ if not a.bundle_prefix:
88
+ return fail("--bundle-prefix is needed with an API key, e.g. com.yourname.tapwright "
89
+ "(the App ID com.yourname.tapwright.* is created for WebDriverAgent)")
90
+ signing.create_identity(signing.AppStoreConnect(a.asc_key_id, a.asc_issuer_id, a.asc_key_file),
91
+ udid, a.bundle_prefix)
92
+ ok("development certificate and profile from App Store Connect")
93
+ elif a.p12:
94
+ signing.import_identity(a.p12, a.p12_password or "", a.profile)
95
+ ok("imported your certificate and profile")
96
+ elif not signing.PROFILE_FILE.exists():
97
+ return fail("no signing identity yet: pass an App Store Connect API key (--asc-key-id, --asc-issuer-id, "
98
+ "--asc-key-file, --bundle-prefix) or --p12 and --profile. See docs/setup.md.")
99
+ profile = signing.read_profile()
100
+ if udid not in profile.get("ProvisionedDevices", []):
101
+ return fail("the profile doesn't include this iPhone; make a new one that does (with an API key, "
102
+ "setup does it)")
103
+ ok(f"profile '{profile['Name']}', expires {profile['ExpirationDate']:%Y-%m-%d}")
104
+
105
+ print("4. WebDriverAgent")
106
+ ipa, runner_id = signing.sign_wda(store.HOME / "build")
107
+ try:
108
+ async with InstallationProxyService(lockdown=lockdown) as proxy:
109
+ await asyncio.wait_for(proxy.install_from_local(ipa), INSTALL_WAIT)
110
+ except asyncio.TimeoutError:
111
+ # iOS keeps an install open while the app it replaces still runs (seen 2026-11-11, IXErrorDomain 48)
112
+ return fail(f"the phone didn't finish installing WebDriverAgent in {INSTALL_WAIT:.0f} s. If a test runner "
113
+ "is still running on the phone (another tool, or Xcode), stop it, then run setup again.")
114
+ ok(f"installed as {runner_id}")
115
+
116
+ print("5. Network pairing (Wi-Fi and remote use)")
117
+ service = await RemotePairingLockdownService.create(lockdown)
118
+ try:
119
+ await service.connect(autopair=True)
120
+ except RemotePairingCompletedError:
121
+ pass
122
+ finally:
123
+ await service.close()
124
+ ok("paired")
125
+ store.save_state(udid=udid, runner_id=runner_id, phone=name)
126
+ print("\nDone. Try: tapwright smoke --app <bundle id of one of your apps>")
127
+ return 0
128
+
129
+
130
+ # ---------- doctor ----------
131
+
132
+ async def doctor(a) -> int:
133
+ from importlib.metadata import version
134
+ from pymobiledevice3 import usbmux
135
+
136
+ from . import signing
137
+ problems = 0
138
+ ok(f"Python {sys.version.split()[0]}, pymobiledevice3 {version('pymobiledevice3')}")
139
+ try:
140
+ devices = await usbmux.list_devices()
141
+ ok(f"device service reachable; on USB: {[d.serial for d in devices if d.connection_type == 'USB'] or 'none'}")
142
+ except Exception as e:
143
+ problems += fail(f"device service unreachable ({e}). Windows: install Apple Devices (Microsoft Store); "
144
+ "Linux: usbmuxd")
145
+ if signing.PROFILE_FILE.exists():
146
+ p = signing.read_profile()
147
+ expired = p["ExpirationDate"].timestamp() < time.time()
148
+ (fail if expired else ok)(f"profile '{p['Name']}' expires {p['ExpirationDate']:%Y-%m-%d}, "
149
+ f"{len(p.get('ProvisionedDevices', []))} device(s)")
150
+ problems += expired
151
+ else:
152
+ problems += fail("no signing identity: run tapwright setup")
153
+ state = store.load_state()
154
+ if state.get("runner_id"):
155
+ ok(f"WebDriverAgent set up for {state.get('phone') or state.get('udid')}")
156
+ else:
157
+ problems += fail("WebDriverAgent not installed yet: run tapwright setup (phone on USB)")
158
+ if state.get("pairing_port"):
159
+ ok(f"network pairing port known: {state['pairing_port']} (tapwright probe refreshes it)")
160
+ else:
161
+ note("network pairing port unknown: run tapwright probe with the phone on the same Wi-Fi")
162
+ print(f"\n{problems} problem(s)." if problems else "\nAll set.")
163
+ return 1 if problems else 0
164
+
165
+
166
+ # ---------- probe: does it work over the network / a VPN? ----------
167
+
168
+ def _tcp(host: str, port: int, timeout: float = 3.0) -> str:
169
+ s = socket.socket(socket.AF_INET6 if ":" in host else socket.AF_INET)
170
+ s.settimeout(timeout)
171
+ try:
172
+ s.connect((host.split("%")[0], port))
173
+ return "open"
174
+ except socket.timeout:
175
+ return "no answer"
176
+ except OSError:
177
+ return "refused"
178
+ finally:
179
+ s.close()
180
+
181
+
182
+ async def probe(a) -> int:
183
+ if _daemon_owns_phone():
184
+ return fail(DAEMON_RUNNING)
185
+ from pymobiledevice3.bonjour import browse_remotepairing
186
+
187
+ from .device import Device
188
+ from .session import Session
189
+ print("1. The phone's network pairing service on this Wi-Fi (mDNS)")
190
+ found = await browse_remotepairing(timeout=6)
191
+ if found:
192
+ port = found[0].port
193
+ store.save_state(pairing_port=port)
194
+ ok(f"port {port} at {[x.full_ip for x in found[0].addresses]}")
195
+ else:
196
+ port = store.load_state().get("pairing_port")
197
+ note(f"not found on this network; using the last known port {port}" if port else
198
+ "not found, and no known port: put the phone on the same Wi-Fi once")
199
+ if not port:
200
+ return 1
201
+ if a.host:
202
+ print(f"2. Reachable at {a.host}?")
203
+ reach = _tcp(a.host, port)
204
+ if reach != "open":
205
+ return fail(f"{a.host}:{port} {reach}: the phone doesn't offer its developer service at that address")
206
+ ok(f"{a.host}:{port} open")
207
+ print("3. Developer tunnel, WebDriverAgent, a screenshot")
208
+ session = Session(Device(host=a.host, port=port if a.host else None))
209
+ try:
210
+ t0 = time.time()
211
+ text, image = await session.look()
212
+ ok(f"over {session.device.via} in {time.time() - t0:.1f} s; {text.splitlines()[0]}")
213
+ shot = Path(f"tapwright-probe-{int(time.time())}.jpg")
214
+ shot.write_bytes(image)
215
+ ok(f"screenshot: {shot}")
216
+ if a.watch:
217
+ print("4. Watching (Ctrl+C stops). Now change the phone's network (e.g. Wi-Fi off).")
218
+ while True:
219
+ alive = await session.device.wda_alive()
220
+ print(f" {time.strftime('%H:%M:%S')} {'alive' if alive else 'LOST'}", flush=True)
221
+ await asyncio.sleep(2)
222
+ return 0
223
+ finally:
224
+ await session.close()
225
+
226
+
227
+ # ---------- smoke test ----------
228
+
229
+ async def smoke(a) -> int:
230
+ if _daemon_owns_phone():
231
+ return fail(DAEMON_RUNNING)
232
+ from .device import Device
233
+ from .session import Session
234
+ session = Session(Device(host=a.host))
235
+ try:
236
+ print(f"Opening {a.app} ...")
237
+ t0 = time.time()
238
+ await session.launch(a.app)
239
+ await asyncio.sleep(a.wait)
240
+ text, image = await session.look()
241
+ print(text)
242
+ shot = Path(f"tapwright-smoke-{int(time.time())}.jpg")
243
+ shot.write_bytes(image)
244
+ ok(f"screen read in {time.time() - t0:.1f} s; screenshot {shot}")
245
+ if not a.keep_open:
246
+ await session.terminate(a.app)
247
+ return 0
248
+ finally:
249
+ await session.close()
250
+
251
+
252
+ # ---------- the daemon ----------
253
+
254
+ def stop_daemon() -> int:
255
+ from . import client
256
+ info = store.read_serve_info()
257
+ if not client.healthy(info):
258
+ print("tapwright serve isn't running")
259
+ return 0
260
+ client.shutdown(info)
261
+ deadline = time.monotonic() + 15
262
+ while time.monotonic() < deadline:
263
+ if client.healthy(store.read_serve_info()) is None:
264
+ print("stopped")
265
+ return 0
266
+ time.sleep(0.3)
267
+ print("asked it to stop; it's still running, see ~/.tapwright/serve.log")
268
+ return 1
269
+
270
+
271
+ def view() -> int:
272
+ import webbrowser
273
+
274
+ from . import client
275
+ try:
276
+ url = client.viewer_url(client.find_or_start())
277
+ except client.DaemonError as e:
278
+ print(f"couldn't start the live view: {e}")
279
+ return 1
280
+ print(f"Tapwright live view: {url}")
281
+ webbrowser.open(url)
282
+ return 0
283
+
284
+
285
+ def skill(a) -> int:
286
+ """`tapwright skill install`: put the agent skill where Claude Code, Codex and other agents look for skills."""
287
+ from . import skills
288
+ if a.action == "path":
289
+ print(skills.SOURCE)
290
+ return 0
291
+ try:
292
+ written = skills.install(a.project)
293
+ except OSError as e:
294
+ print(f"couldn't install the skill: {e}")
295
+ return 1
296
+ print(skills.installed_text(written))
297
+ return 0
298
+
299
+
300
+ def main() -> int:
301
+ # pymobiledevice3's test-runner channel logs every message it drops while the runner shuts down as a warning
302
+ # ("received connection termination: <DTXMessage …>", "No channel registered …"): expected, and only noise on a
303
+ # new user's console (seen in a clean install's `tapwright smoke`). Its errors still show.
304
+ logging.getLogger("pymobiledevice3.dtx").setLevel(logging.ERROR)
305
+ ap = argparse.ArgumentParser(prog="tapwright", description=__doc__,
306
+ formatter_class=argparse.RawDescriptionHelpFormatter)
307
+ sub = ap.add_subparsers(dest="cmd", required=True)
308
+ p = sub.add_parser("setup")
309
+ p.add_argument("--udid")
310
+ p.add_argument("--asc-key-id")
311
+ p.add_argument("--asc-issuer-id")
312
+ p.add_argument("--asc-key-file")
313
+ p.add_argument("--bundle-prefix")
314
+ p.add_argument("--p12")
315
+ p.add_argument("--p12-password")
316
+ p.add_argument("--profile")
317
+ sub.add_parser("doctor")
318
+ p = sub.add_parser("smoke")
319
+ p.add_argument("--app", required=True, help="bundle id, e.g. com.example.app")
320
+ p.add_argument("--wait", type=float, default=3.0, help="seconds to let the app start (default 3)")
321
+ p.add_argument("--keep-open", action="store_true")
322
+ p.add_argument("--host")
323
+ p = sub.add_parser("probe")
324
+ p.add_argument("--host", help="an address of the phone to try, e.g. its Tailscale IP")
325
+ p.add_argument("--watch", action="store_true")
326
+ p = sub.add_parser("serve")
327
+ p.add_argument("--port", type=int, help="default 8417; a free port if that one is taken")
328
+ p.add_argument("--phone", help="reach the phone at this address (e.g. its Tailscale IP) instead of USB or Wi-Fi")
329
+ p.add_argument("--keep-alive", action="store_true", help="don't stop after 30 minutes without use")
330
+ p.add_argument("--new-token", action="store_true", help="replace the live view's access token")
331
+ p.add_argument("--stop", action="store_true", help="stop the running daemon")
332
+ p.add_argument("--settle-on-video", action="store_true",
333
+ help="experimental, with --video: actions watch the video's frames and the JPEG stream stays "
334
+ "closed (faster over Wi-Fi; a stalled mirror can make an action answer 'No visible change')")
335
+ p.add_argument("--video", action="store_true",
336
+ help="show the live view as video: start the phone's Screen Mirroring to this computer (same "
337
+ "Wi-Fi; needs UxPlay and `pip install tapwright[video]`)")
338
+ sub.add_parser("view")
339
+ p = sub.add_parser("skill", help="the agent skill: how to test with Tapwright, for Claude Code, Codex and others")
340
+ p.add_argument("action", choices=("install", "path"),
341
+ help="install: copy it to .claude/skills and .agents/skills; path: print where it ships")
342
+ p.add_argument("--project", metavar="DIR", help="install into this project's folder instead of your home folder")
343
+ p = sub.add_parser("mcp")
344
+ p.add_argument("--screenshots", metavar="DIR",
345
+ help="save screenshots in DIR (the newest 50) and answer with their path, instead of sending images")
346
+ scope = p.add_mutually_exclusive_group()
347
+ scope.add_argument("--apps", metavar="APP[,APP]",
348
+ help="the apps the agent may read and touch, as bundle ids or exact names (personal mode, the "
349
+ "default: without this list the agent can do nothing)")
350
+ scope.add_argument("--dedicated", action="store_true",
351
+ help="the phone is kept for testing: the agent may use every app on it")
352
+ a = ap.parse_args()
353
+ if a.cmd == "mcp":
354
+ from . import mcp_server
355
+ apps = [x.strip() for x in (a.apps or "").split(",") if x.strip()]
356
+ mcp_server.run(screenshots=a.screenshots, apps=apps, dedicated=a.dedicated)
357
+ return 0
358
+ if a.cmd == "view":
359
+ return view()
360
+ if a.cmd == "skill":
361
+ return skill(a)
362
+ if a.cmd == "serve" and a.stop:
363
+ return stop_daemon()
364
+ from .device import run
365
+ if a.cmd == "serve":
366
+ from . import server
367
+ port = a.port if a.port is not None else server.DEFAULT_PORT
368
+ return run(lambda: server.serve(port=port, keep_alive=a.keep_alive, new_token=a.new_token, phone=a.phone,
369
+ video=a.video, settle_on_video=a.settle_on_video))
370
+ return run({"setup": setup, "doctor": doctor, "smoke": smoke, "probe": probe}[a.cmd], a)
371
+
372
+
373
+ if __name__ == "__main__":
374
+ sys.exit(main())
tapwright/client.py ADDED
@@ -0,0 +1,151 @@
1
+ """Find the running `tapwright serve`, or start it, and call its API (standard library only)."""
2
+ import http.client
3
+ import json
4
+ import os
5
+ import socket
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+
12
+ from . import store
13
+
14
+
15
+ class DaemonError(RuntimeError):
16
+ """A refusal or error from the daemon, with its code (docs/VIEWER-PROTOCOL.md)."""
17
+
18
+ def __init__(self, code: str, message: str) -> None:
19
+ super().__init__(message)
20
+ self.code = code
21
+
22
+
23
+ def base_url(info: dict) -> str:
24
+ return f"http://127.0.0.1:{info['port']}"
25
+
26
+
27
+ def viewer_url(info: dict) -> str:
28
+ return f"{base_url(info)}/?token={info['token']}"
29
+
30
+
31
+ SCOPE: dict[str, str] = {} # the headers that say what this process's agent may use; none: no apps at all
32
+
33
+
34
+ def set_scope(apps: list[str] | None = None, dedicated: bool = False) -> None:
35
+ """Personal mode (the default): the agent may read and touch only `apps` (bundle ids or exact names).
36
+ `dedicated`: the phone is kept for testing, so there is no limit. The daemon enforces it on every call."""
37
+ SCOPE.clear()
38
+ if dedicated:
39
+ SCOPE["X-Tapwright-Mode"] = "dedicated"
40
+ elif apps:
41
+ SCOPE["X-Tapwright-Apps"] = ",".join(apps)
42
+
43
+
44
+ def _request(info: dict, method: str, path: str, body: dict | None = None, timeout: float = 10.0,
45
+ client_id: str | None = None) -> dict:
46
+ headers = {"Authorization": f"Bearer {info['token']}", "Content-Type": "application/json"}
47
+ if client_id:
48
+ headers["X-Tapwright-Client"] = client_id
49
+ headers.update(SCOPE) # an agent's calls carry the apps it was given, or that the device is dedicated
50
+ data = json.dumps(body).encode() if body is not None else None
51
+ req = urllib.request.Request(base_url(info) + path, data=data, method=method, headers=headers)
52
+ try:
53
+ with urllib.request.urlopen(req, timeout=timeout) as r:
54
+ return json.loads(r.read() or b"{}")
55
+ except urllib.error.HTTPError as e:
56
+ try:
57
+ err = json.loads(e.read() or b"{}").get("error") or {}
58
+ except ValueError:
59
+ err = {}
60
+ raise DaemonError(err.get("code", f"http-{e.code}"), err.get("message", f"HTTP {e.code}")) from None
61
+
62
+
63
+ def healthy(info: dict, timeout: float = 2.0) -> dict | None:
64
+ """The daemon's status if it answers with this token, else None."""
65
+ if not info.get("port") or not info.get("token"):
66
+ return None
67
+ try:
68
+ status = _request(info, "GET", "/api/health", timeout=timeout)
69
+ except (OSError, DaemonError, ValueError, http.client.HTTPException):
70
+ return None
71
+ return status if isinstance(status, dict) and "protocol" in status else None
72
+
73
+
74
+ def start_daemon() -> subprocess.Popen:
75
+ """`tapwright serve` in the background: no console window, and it outlives the agent that
76
+ started it (on Windows it leaves the agent host's job object when Windows allows that)."""
77
+ cmd = [sys.executable, "-m", "tapwright", "serve"]
78
+ quiet = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL,
79
+ "close_fds": True}
80
+ if os.name != "nt":
81
+ return subprocess.Popen(cmd, start_new_session=True, **quiet)
82
+ flags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
83
+ try:
84
+ return subprocess.Popen(cmd, creationflags=flags | subprocess.CREATE_BREAKAWAY_FROM_JOB, **quiet)
85
+ except OSError: # the job doesn't allow breaking away
86
+ return subprocess.Popen(cmd, creationflags=flags, **quiet)
87
+
88
+
89
+ def find_or_start(wait: float = 60.0) -> dict:
90
+ info = store.read_serve_info()
91
+ if healthy(info):
92
+ return info
93
+ proc = start_daemon()
94
+ deadline = time.monotonic() + wait
95
+ while time.monotonic() < deadline:
96
+ time.sleep(0.5)
97
+ info = store.read_serve_info()
98
+ if healthy(info):
99
+ return info
100
+ if proc is not None and proc.poll() is not None:
101
+ info = store.read_serve_info() # exit code 0 means "already running"
102
+ if healthy(info):
103
+ return info
104
+ raise DaemonError("reconnecting", "tapwright serve exited at start; see ~/.tapwright/serve.log")
105
+ raise DaemonError("reconnecting", "tapwright serve didn't start; see ~/.tapwright/serve.log")
106
+
107
+
108
+ def _refused(e: BaseException) -> bool:
109
+ return isinstance(e, ConnectionRefusedError) or (
110
+ isinstance(e, urllib.error.URLError) and isinstance(e.reason, ConnectionRefusedError))
111
+
112
+
113
+ def _timed_out(e: BaseException) -> bool:
114
+ return isinstance(e, (TimeoutError, socket.timeout)) or (
115
+ isinstance(e, urllib.error.URLError) and isinstance(e.reason, (TimeoutError, socket.timeout)))
116
+
117
+
118
+ def _attempt(info: dict, tool: str, args: dict, timeout: float, client_id: str, retry_ok: bool) -> dict:
119
+ """One request. Only a refused connection (nothing was sent) is passed up for a retry."""
120
+ try:
121
+ return _request(info, "POST", f"/api/{tool}", args, timeout, client_id)
122
+ except DaemonError:
123
+ raise
124
+ except (OSError, http.client.HTTPException) as e:
125
+ if retry_ok and _refused(e):
126
+ raise
127
+ if _timed_out(e):
128
+ raise DaemonError("timeout", f"no answer from tapwright serve within {timeout:.0f} s; "
129
+ "look at the screen before retrying") from None
130
+ raise DaemonError("reconnecting", str(e)) from None
131
+
132
+
133
+ def call(tool: str, args: dict, client_id: str, timeout: float = 120.0) -> dict:
134
+ """One agent tool in the daemon: {"text", "image", "image_size"}. Raises DaemonError for refusals.
135
+ It never sends an action twice: it retries only when the connection was refused."""
136
+ info = find_or_start()
137
+ try:
138
+ return _attempt(info, tool, args, timeout, client_id, retry_ok=True)
139
+ except OSError: # refused: the daemon went away between the check and the call
140
+ info = find_or_start()
141
+ return _attempt(info, tool, args, timeout, client_id, retry_ok=False)
142
+
143
+
144
+ def shutdown(info: dict) -> None:
145
+ """Ask the daemon to stop. It may drop the connection before its answer is out (seen now and then with a
146
+ stream open): that is the stop happening, not a failure, so `tapwright serve --stop` doesn't end in a
147
+ traceback. A refusal (a wrong token) is still raised."""
148
+ try:
149
+ _request(info, "POST", "/api/shutdown", {})
150
+ except (OSError, ValueError, http.client.HTTPException):
151
+ pass