agentremote-cli 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.
remotecli/agent.py ADDED
@@ -0,0 +1,673 @@
1
+ """
2
+ Agent-friendly remotecli wrappers.
3
+
4
+ These commands hide mechanical details that models frequently get wrong:
5
+ waiting, result.output decoding, and pid/window_id discovery.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Iterable
12
+
13
+ from .agent_cache import WindowCache, WindowRef
14
+ from .agent_modes import _LOCATE_MODES, _OBSERVE_MODES, _PROBE_MODES
15
+ from .agent_open import open_app_window
16
+ from .agent_projection import _compact_window_state
17
+ from .agent_result import _error, _normalize_task_result
18
+ from .agent_utils import (
19
+ _normalize_mode,
20
+ _normalize_scroll_amount,
21
+ _normalize_scroll_by,
22
+ _normalize_scroll_direction,
23
+ _target_texts,
24
+ )
25
+ from .agent_window import WindowResolver
26
+ from .client import RemoteCliClient
27
+ from .cua import CuaCommands
28
+ from .shell import ShellCommands
29
+
30
+ __all__ = ["AgentCommands", "WindowCache", "WindowRef"]
31
+
32
+
33
+ class AgentCommands:
34
+ """High-level commands intended for AI agents."""
35
+
36
+ def __init__(
37
+ self,
38
+ client: RemoteCliClient,
39
+ window_cache_path: str | Path | None = None,
40
+ default_session_id: str = "",
41
+ ) -> None:
42
+ self._client = client
43
+ self._shell = ShellCommands(client)
44
+ self._cua = CuaCommands(client)
45
+ self._window_cache = WindowCache(window_cache_path)
46
+ self._window_resolver = WindowResolver(
47
+ client,
48
+ self._cua,
49
+ self._window_cache,
50
+ self._wait_and_unpack,
51
+ default_session_id=default_session_id,
52
+ )
53
+
54
+ def ps(
55
+ self,
56
+ script: str,
57
+ cwd: str = "",
58
+ timeout_sec: int = 60,
59
+ wait_timeout: float = 30.0,
60
+ poll_interval: float = 0.1,
61
+ ) -> Dict[str, Any]:
62
+ submitted = self._shell.exec_script(
63
+ script,
64
+ shell="powershell",
65
+ cwd=cwd,
66
+ timeout_sec=timeout_sec,
67
+ )
68
+ return self._wait_and_unpack(submitted, "ps", wait_timeout, poll_interval)
69
+
70
+ def cmd(
71
+ self,
72
+ command: str,
73
+ cwd: str = "",
74
+ timeout_sec: int = 60,
75
+ wait_timeout: float = 30.0,
76
+ poll_interval: float = 0.1,
77
+ ) -> Dict[str, Any]:
78
+ submitted = self._shell.exec(command, cwd=cwd, timeout_sec=timeout_sec)
79
+ return self._wait_and_unpack(submitted, "cmd", wait_timeout, poll_interval)
80
+
81
+ def open(
82
+ self,
83
+ app_query: str,
84
+ launch_path: str = "",
85
+ app_args: str = "",
86
+ start_if_missing: bool = True,
87
+ foreground: bool = True,
88
+ launch_timeout: int = 15,
89
+ wait_timeout: float = 30.0,
90
+ poll_interval: float = 0.1,
91
+ session_id: str = "",
92
+ use_window_cache: bool = True,
93
+ ) -> Dict[str, Any]:
94
+ return open_app_window(
95
+ self._client,
96
+ self._shell,
97
+ self._window_cache,
98
+ self._wait_and_unpack,
99
+ app_query,
100
+ session_id=self._resolve_session_id(session_id),
101
+ use_window_cache=use_window_cache,
102
+ launch_path=launch_path,
103
+ app_args=app_args,
104
+ start_if_missing=start_if_missing,
105
+ foreground=foreground,
106
+ launch_timeout=launch_timeout,
107
+ wait_timeout=wait_timeout,
108
+ poll_interval=poll_interval,
109
+ )
110
+
111
+ def observe(
112
+ self,
113
+ app_query: str,
114
+ mode: str = "uia",
115
+ output_format: str = "compact",
116
+ element_limit: int = 20,
117
+ targets: Iterable[str] | str | None = None,
118
+ wait_timeout: float = 30.0,
119
+ poll_interval: float = 0.1,
120
+ session_id: str = "",
121
+ use_window_cache: bool = True,
122
+ refresh_window: bool = False,
123
+ ) -> Dict[str, Any]:
124
+ output_format = output_format.strip().lower()
125
+ if output_format not in {"compact", "full"}:
126
+ return _error(
127
+ "observe",
128
+ f"unsupported observe format: {output_format}",
129
+ supported_formats=["compact", "full"],
130
+ )
131
+
132
+ mode = _normalize_mode(mode, default="uia")
133
+ if mode not in _OBSERVE_MODES:
134
+ return _error(
135
+ "observe",
136
+ f"unsupported observe mode: {mode}",
137
+ supported_modes=sorted(_OBSERVE_MODES),
138
+ )
139
+
140
+ ref, error = self._resolve_window(
141
+ app_query,
142
+ wait_timeout,
143
+ poll_interval,
144
+ session_id=session_id,
145
+ use_window_cache=use_window_cache,
146
+ refresh_window=refresh_window,
147
+ )
148
+ if error:
149
+ return error
150
+
151
+ assert ref is not None
152
+ self._foreground_window(ref, wait_timeout, poll_interval)
153
+ state = self._wait_and_unpack(
154
+ self._cua.get_window_state(ref.pid, ref.window_id, mode),
155
+ "observe",
156
+ wait_timeout,
157
+ poll_interval,
158
+ )
159
+ if self._should_refresh_cached_window(ref, state, session_id, use_window_cache):
160
+ ref, error = self._refresh_window(app_query, wait_timeout, poll_interval, session_id)
161
+ if error:
162
+ return error
163
+ assert ref is not None
164
+ self._foreground_window(ref, wait_timeout, poll_interval)
165
+ state = self._wait_and_unpack(
166
+ self._cua.get_window_state(ref.pid, ref.window_id, mode),
167
+ "observe",
168
+ wait_timeout,
169
+ poll_interval,
170
+ )
171
+ payload = state.get("json")
172
+ title = self._state_title(payload) or ref.title
173
+ if output_format == "full":
174
+ return {
175
+ "ok": bool(state.get("ok")),
176
+ "action": "observe",
177
+ "format": "full",
178
+ "mode": mode,
179
+ "app": app_query,
180
+ "pid": ref.pid,
181
+ "window_id": ref.window_id,
182
+ "title": title,
183
+ "window_cached": ref.cached,
184
+ "state": payload,
185
+ "result": state,
186
+ **self._window_cache_context(),
187
+ }
188
+
189
+ compact_state = _compact_window_state(
190
+ payload,
191
+ title=title,
192
+ trace_id=state.get("trace_id"),
193
+ targets=targets,
194
+ element_limit=element_limit,
195
+ )
196
+ return {
197
+ "ok": bool(state.get("ok")),
198
+ "action": "observe",
199
+ "format": "compact",
200
+ "mode": mode,
201
+ "app": app_query,
202
+ "pid": ref.pid,
203
+ "window_id": ref.window_id,
204
+ "title": title,
205
+ "window": {
206
+ "pid": ref.pid,
207
+ "window_id": ref.window_id,
208
+ "title": title,
209
+ },
210
+ "window_cached": ref.cached,
211
+ "state": compact_state,
212
+ "trace_id": state.get("trace_id"),
213
+ "status": state.get("status"),
214
+ "result_status": state.get("result_status"),
215
+ "error": state.get("error") or "",
216
+ **self._window_cache_context(),
217
+ }
218
+
219
+ def click(
220
+ self,
221
+ app_query: str,
222
+ target: str,
223
+ locate_mode: str = "smart",
224
+ wait_timeout: float = 30.0,
225
+ poll_interval: float = 0.1,
226
+ session_id: str = "",
227
+ use_window_cache: bool = True,
228
+ refresh_window: bool = False,
229
+ ) -> Dict[str, Any]:
230
+ locate_mode = _normalize_mode(locate_mode, default="smart")
231
+ if locate_mode not in _LOCATE_MODES:
232
+ return _error(
233
+ "click",
234
+ f"unsupported locate mode: {locate_mode}",
235
+ supported_modes=sorted(_LOCATE_MODES),
236
+ )
237
+ ref, error = self._resolve_window(
238
+ app_query,
239
+ wait_timeout,
240
+ poll_interval,
241
+ session_id=session_id,
242
+ use_window_cache=use_window_cache,
243
+ refresh_window=refresh_window,
244
+ )
245
+ if error:
246
+ return error
247
+
248
+ assert ref is not None
249
+ result = self._wait_and_unpack(
250
+ self._cua.click(ref.pid, ref.window_id, target=target, locate_mode=locate_mode),
251
+ "click",
252
+ wait_timeout,
253
+ poll_interval,
254
+ )
255
+ if self._should_refresh_cached_window(ref, result, session_id, use_window_cache):
256
+ ref, error = self._refresh_window(app_query, wait_timeout, poll_interval, session_id)
257
+ if error:
258
+ return error
259
+ assert ref is not None
260
+ result = self._wait_and_unpack(
261
+ self._cua.click(ref.pid, ref.window_id, target=target, locate_mode=locate_mode),
262
+ "click",
263
+ wait_timeout,
264
+ poll_interval,
265
+ )
266
+ extra = {"locate_mode": locate_mode} if locate_mode else {}
267
+ return self._with_window_context(result, "click", ref, target=target, **extra)
268
+
269
+ def probe(
270
+ self,
271
+ app_query: str,
272
+ targets: Iterable[str] | str,
273
+ mode: str = "smart",
274
+ wait_timeout: float = 30.0,
275
+ poll_interval: float = 0.1,
276
+ session_id: str = "",
277
+ use_window_cache: bool = True,
278
+ refresh_window: bool = False,
279
+ ) -> Dict[str, Any]:
280
+ mode = _normalize_mode(mode, default="smart")
281
+ if mode not in _PROBE_MODES:
282
+ return _error(
283
+ "probe",
284
+ f"unsupported probe mode: {mode}",
285
+ supported_modes=sorted(_PROBE_MODES),
286
+ )
287
+ target_list = _target_texts(targets)
288
+ if not target_list:
289
+ return _error("probe", "probe requires at least one target")
290
+
291
+ ref, error = self._resolve_window(
292
+ app_query,
293
+ wait_timeout,
294
+ poll_interval,
295
+ session_id=session_id,
296
+ use_window_cache=use_window_cache,
297
+ refresh_window=refresh_window,
298
+ )
299
+ if error:
300
+ return error
301
+
302
+ assert ref is not None
303
+ if mode != "uia":
304
+ self._foreground_window(ref, wait_timeout, poll_interval)
305
+ result = self._wait_and_unpack(
306
+ self._cua.probe_window(ref.pid, ref.window_id, target_list, mode=mode),
307
+ "probe",
308
+ wait_timeout,
309
+ poll_interval,
310
+ )
311
+ if self._should_refresh_cached_window(ref, result, session_id, use_window_cache):
312
+ ref, error = self._refresh_window(app_query, wait_timeout, poll_interval, session_id)
313
+ if error:
314
+ return error
315
+ assert ref is not None
316
+ if mode != "uia":
317
+ self._foreground_window(ref, wait_timeout, poll_interval)
318
+ result = self._wait_and_unpack(
319
+ self._cua.probe_window(ref.pid, ref.window_id, target_list, mode=mode),
320
+ "probe",
321
+ wait_timeout,
322
+ poll_interval,
323
+ )
324
+ payload = result.get("json")
325
+ if not isinstance(payload, dict):
326
+ payload = result.get("output") if isinstance(result.get("output"), dict) else {}
327
+ matches = payload.get("matches", []) if isinstance(payload.get("matches"), list) else []
328
+ missing = payload.get("missing", target_list) if isinstance(payload.get("missing"), list) else target_list
329
+ return {
330
+ "ok": bool(result.get("ok")),
331
+ "action": "probe",
332
+ "app": app_query,
333
+ "pid": ref.pid,
334
+ "window_id": ref.window_id,
335
+ "title": ref.title,
336
+ "window_cached": ref.cached,
337
+ "mode": payload.get("mode", mode),
338
+ "targets": payload.get("targets", target_list),
339
+ "matches": matches,
340
+ "missing": missing,
341
+ "any_found": bool(payload.get("any_found", bool(matches))),
342
+ "all_found": bool(payload.get("all_found", not missing)),
343
+ "probes": payload.get("probes", []),
344
+ "trace_id": result.get("trace_id"),
345
+ "status": result.get("status"),
346
+ "result_status": result.get("result_status"),
347
+ "error": result.get("error") or "",
348
+ **self._window_cache_context(),
349
+ }
350
+
351
+ def type_text(
352
+ self,
353
+ app_query: str,
354
+ target: str,
355
+ text: str,
356
+ locate_mode: str = "smart",
357
+ wait_timeout: float = 30.0,
358
+ poll_interval: float = 0.1,
359
+ session_id: str = "",
360
+ use_window_cache: bool = True,
361
+ refresh_window: bool = False,
362
+ ) -> Dict[str, Any]:
363
+ locate_mode = _normalize_mode(locate_mode, default="smart")
364
+ if locate_mode not in _LOCATE_MODES:
365
+ return _error(
366
+ "type",
367
+ f"unsupported locate mode: {locate_mode}",
368
+ supported_modes=sorted(_LOCATE_MODES),
369
+ )
370
+ ref, error = self._resolve_window(
371
+ app_query,
372
+ wait_timeout,
373
+ poll_interval,
374
+ session_id=session_id,
375
+ use_window_cache=use_window_cache,
376
+ refresh_window=refresh_window,
377
+ )
378
+ if error:
379
+ return error
380
+
381
+ assert ref is not None
382
+ result = self._wait_and_unpack(
383
+ self._cua.type_text(
384
+ ref.pid,
385
+ target,
386
+ text,
387
+ window_id=ref.window_id,
388
+ locate_mode=locate_mode,
389
+ ),
390
+ "type",
391
+ wait_timeout,
392
+ poll_interval,
393
+ )
394
+ if self._should_refresh_cached_window(ref, result, session_id, use_window_cache):
395
+ ref, error = self._refresh_window(app_query, wait_timeout, poll_interval, session_id)
396
+ if error:
397
+ return error
398
+ assert ref is not None
399
+ result = self._wait_and_unpack(
400
+ self._cua.type_text(
401
+ ref.pid,
402
+ target,
403
+ text,
404
+ window_id=ref.window_id,
405
+ locate_mode=locate_mode,
406
+ ),
407
+ "type",
408
+ wait_timeout,
409
+ poll_interval,
410
+ )
411
+ extra = {"locate_mode": locate_mode} if locate_mode else {}
412
+ return self._with_window_context(result, "type", ref, target=target, **extra)
413
+
414
+ def key(
415
+ self,
416
+ app_query: str,
417
+ key: str,
418
+ wait_timeout: float = 30.0,
419
+ poll_interval: float = 0.1,
420
+ session_id: str = "",
421
+ use_window_cache: bool = True,
422
+ refresh_window: bool = False,
423
+ ) -> Dict[str, Any]:
424
+ ref, error = self._resolve_window(
425
+ app_query,
426
+ wait_timeout,
427
+ poll_interval,
428
+ session_id=session_id,
429
+ use_window_cache=use_window_cache,
430
+ refresh_window=refresh_window,
431
+ )
432
+ if error:
433
+ return error
434
+
435
+ assert ref is not None
436
+ result = self._wait_and_unpack(
437
+ self._cua.press_key(ref.pid, key, window_id=ref.window_id),
438
+ "key",
439
+ wait_timeout,
440
+ poll_interval,
441
+ )
442
+ if self._should_refresh_cached_window(ref, result, session_id, use_window_cache):
443
+ ref, error = self._refresh_window(app_query, wait_timeout, poll_interval, session_id)
444
+ if error:
445
+ return error
446
+ assert ref is not None
447
+ result = self._wait_and_unpack(
448
+ self._cua.press_key(ref.pid, key, window_id=ref.window_id),
449
+ "key",
450
+ wait_timeout,
451
+ poll_interval,
452
+ )
453
+ return self._with_window_context(result, "key", ref, key=key)
454
+
455
+ def hotkey(
456
+ self,
457
+ app_query: str,
458
+ keys: str,
459
+ wait_timeout: float = 30.0,
460
+ poll_interval: float = 0.1,
461
+ session_id: str = "",
462
+ use_window_cache: bool = True,
463
+ refresh_window: bool = False,
464
+ ) -> Dict[str, Any]:
465
+ ref, error = self._resolve_window(
466
+ app_query,
467
+ wait_timeout,
468
+ poll_interval,
469
+ session_id=session_id,
470
+ use_window_cache=use_window_cache,
471
+ refresh_window=refresh_window,
472
+ )
473
+ if error:
474
+ return error
475
+
476
+ assert ref is not None
477
+ result = self._wait_and_unpack(
478
+ self._cua.hotkey(ref.pid, keys, window_id=ref.window_id),
479
+ "hotkey",
480
+ wait_timeout,
481
+ poll_interval,
482
+ )
483
+ if self._should_refresh_cached_window(ref, result, session_id, use_window_cache):
484
+ ref, error = self._refresh_window(app_query, wait_timeout, poll_interval, session_id)
485
+ if error:
486
+ return error
487
+ assert ref is not None
488
+ result = self._wait_and_unpack(
489
+ self._cua.hotkey(ref.pid, keys, window_id=ref.window_id),
490
+ "hotkey",
491
+ wait_timeout,
492
+ poll_interval,
493
+ )
494
+ return self._with_window_context(result, "hotkey", ref, keys=keys)
495
+
496
+ def scroll(
497
+ self,
498
+ app_query: str,
499
+ direction: str = "down",
500
+ amount: int = 3,
501
+ by: str = "line",
502
+ dispatch: str = "",
503
+ wait_timeout: float = 30.0,
504
+ poll_interval: float = 0.1,
505
+ session_id: str = "",
506
+ use_window_cache: bool = True,
507
+ refresh_window: bool = False,
508
+ ) -> Dict[str, Any]:
509
+ ref, error = self._resolve_window(
510
+ app_query,
511
+ wait_timeout,
512
+ poll_interval,
513
+ session_id=session_id,
514
+ use_window_cache=use_window_cache,
515
+ refresh_window=refresh_window,
516
+ )
517
+ if error:
518
+ return error
519
+
520
+ assert ref is not None
521
+ direction = _normalize_scroll_direction(direction)
522
+ amount = _normalize_scroll_amount(amount)
523
+ by = _normalize_scroll_by(by)
524
+ result = self._wait_and_unpack(
525
+ self._cua.scroll(
526
+ ref.pid,
527
+ ref.window_id,
528
+ direction=direction,
529
+ amount=amount,
530
+ by=by,
531
+ dispatch=dispatch,
532
+ ),
533
+ "scroll",
534
+ wait_timeout,
535
+ poll_interval,
536
+ )
537
+ if self._should_refresh_cached_window(ref, result, session_id, use_window_cache):
538
+ ref, error = self._refresh_window(app_query, wait_timeout, poll_interval, session_id)
539
+ if error:
540
+ return error
541
+ assert ref is not None
542
+ result = self._wait_and_unpack(
543
+ self._cua.scroll(
544
+ ref.pid,
545
+ ref.window_id,
546
+ direction=direction,
547
+ amount=amount,
548
+ by=by,
549
+ dispatch=dispatch,
550
+ ),
551
+ "scroll",
552
+ wait_timeout,
553
+ poll_interval,
554
+ )
555
+ return self._with_window_context(
556
+ result,
557
+ "scroll",
558
+ ref,
559
+ direction=direction,
560
+ amount=amount,
561
+ by=by,
562
+ **({"dispatch": dispatch} if dispatch else {}),
563
+ )
564
+
565
+ def clear_session(self, session_id: str = "") -> Dict[str, Any]:
566
+ session = self._resolve_session_id(session_id)
567
+ if not session:
568
+ return _error("session-clear", "session is required")
569
+ self._window_cache.clear_session(self._client, session)
570
+ return {
571
+ "ok": True,
572
+ "action": "session-clear",
573
+ "session": session,
574
+ **self._window_cache_context(),
575
+ }
576
+
577
+ def _resolve_window(
578
+ self,
579
+ app_query: str,
580
+ wait_timeout: float,
581
+ poll_interval: float,
582
+ session_id: str = "",
583
+ use_window_cache: bool = True,
584
+ refresh_window: bool = False,
585
+ ) -> tuple[WindowRef | None, Dict[str, Any] | None]:
586
+ return self._window_resolver.resolve_window(
587
+ app_query,
588
+ wait_timeout,
589
+ poll_interval,
590
+ session_id=session_id,
591
+ use_window_cache=use_window_cache,
592
+ refresh_window=refresh_window,
593
+ )
594
+
595
+ def _foreground_window(
596
+ self,
597
+ ref: WindowRef,
598
+ wait_timeout: float,
599
+ poll_interval: float,
600
+ ) -> None:
601
+ self._window_resolver.foreground_window(ref, wait_timeout, poll_interval)
602
+
603
+ def _refresh_window(
604
+ self,
605
+ app_query: str,
606
+ wait_timeout: float,
607
+ poll_interval: float,
608
+ session_id: str,
609
+ ) -> tuple[WindowRef | None, Dict[str, Any] | None]:
610
+ return self._window_resolver.refresh_window(
611
+ app_query,
612
+ wait_timeout,
613
+ poll_interval,
614
+ session_id,
615
+ )
616
+
617
+ def _should_refresh_cached_window(
618
+ self,
619
+ ref: WindowRef,
620
+ result: Dict[str, Any],
621
+ session_id: str,
622
+ use_window_cache: bool,
623
+ ) -> bool:
624
+ return self._window_resolver.should_refresh_cached_window(
625
+ ref,
626
+ result,
627
+ session_id,
628
+ use_window_cache,
629
+ )
630
+
631
+ def _resolve_session_id(self, session_id: str = "") -> str:
632
+ return self._window_resolver.resolve_session_id(session_id)
633
+
634
+ def _window_cache_context(self) -> Dict[str, Any]:
635
+ return self._window_resolver.cache_context()
636
+
637
+ def _wait_and_unpack(
638
+ self,
639
+ submitted: Dict[str, Any],
640
+ action: str,
641
+ wait_timeout: float,
642
+ poll_interval: float,
643
+ ) -> Dict[str, Any]:
644
+ trace_id = submitted.get("trace_id")
645
+ if not trace_id:
646
+ return _normalize_task_result(submitted, action)
647
+ data = self._client.poll_result(trace_id, timeout_sec=wait_timeout, interval=poll_interval)
648
+ return _normalize_task_result(data, action)
649
+
650
+ @staticmethod
651
+ def _state_title(payload: Any) -> str:
652
+ if isinstance(payload, dict):
653
+ return str(payload.get("title") or payload.get("window_title") or "")
654
+ return ""
655
+
656
+ def _with_window_context(
657
+ self,
658
+ result: Dict[str, Any],
659
+ action: str,
660
+ ref: WindowRef,
661
+ **extra: Any,
662
+ ) -> Dict[str, Any]:
663
+ return {
664
+ "ok": bool(result.get("ok")),
665
+ "action": action,
666
+ "pid": ref.pid,
667
+ "window_id": ref.window_id,
668
+ "title": ref.title,
669
+ "window_cached": ref.cached,
670
+ **extra,
671
+ "result": result,
672
+ **self._window_cache_context(),
673
+ }