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.
@@ -0,0 +1,274 @@
1
+ """Persistent window cache for agent-friendly GUI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import tempfile
10
+ import time
11
+ from typing import Any, Dict, Iterable
12
+
13
+ from .agent_utils import _coerce_float, _coerce_int
14
+ from .client import RemoteCliClient
15
+
16
+
17
+ _WINDOW_CACHE_FILENAME = "remotecli_window_cache.json"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class WindowRef:
22
+ pid: int
23
+ window_id: int
24
+ title: str
25
+ app: Dict[str, Any]
26
+ window: Dict[str, Any]
27
+ cached: bool = False
28
+
29
+
30
+ def _default_window_cache_path() -> Path:
31
+ return _default_window_cache_paths()[0]
32
+
33
+
34
+ def _default_window_cache_paths() -> list[Path]:
35
+ explicit_path = os.environ.get("REMOTECLI_WINDOW_CACHE", "").strip()
36
+ if explicit_path:
37
+ return [Path(explicit_path)]
38
+
39
+ candidates: list[Path] = []
40
+ base_dir = os.environ.get("LOCALAPPDATA", "").strip()
41
+ if base_dir:
42
+ candidates.append(Path(base_dir) / "AgentRemote" / _WINDOW_CACHE_FILENAME)
43
+
44
+ state_dir = os.environ.get("AGENTREMOTE_STATE_DIR", "").strip()
45
+ if state_dir:
46
+ candidates.append(Path(state_dir) / _WINDOW_CACHE_FILENAME)
47
+
48
+ runtime_state_dir = Path(__file__).resolve().parents[1] / "client" / "runtime_state"
49
+ candidates.append(runtime_state_dir / _WINDOW_CACHE_FILENAME)
50
+ candidates.append(Path.home() / ".agentremote" / _WINDOW_CACHE_FILENAME)
51
+ candidates.append(Path(tempfile.gettempdir()) / "AgentRemote" / _WINDOW_CACHE_FILENAME)
52
+ return _dedupe_paths(candidates)
53
+
54
+
55
+ def _dedupe_paths(paths: Iterable[Path]) -> list[Path]:
56
+ result: list[Path] = []
57
+ seen: set[str] = set()
58
+ for path in paths:
59
+ key = os.path.normcase(str(path.expanduser()))
60
+ if key in seen:
61
+ continue
62
+ seen.add(key)
63
+ result.append(path)
64
+ return result
65
+
66
+
67
+ def _session_cache_key(client: RemoteCliClient, session: str) -> str:
68
+ return "|".join(
69
+ [
70
+ str(getattr(client, "middleware_url", "")),
71
+ str(getattr(client, "tenant_id", "")),
72
+ str(getattr(client, "node_id", "")),
73
+ session.strip(),
74
+ ]
75
+ )
76
+
77
+
78
+ def _normalize_cache_key(value: str) -> str:
79
+ return " ".join(value.strip().lower().split())
80
+
81
+
82
+ class WindowCache:
83
+ """Persistent session-scoped cache for app pid/window_id lookups."""
84
+
85
+ def __init__(self, path: str | Path | None = None) -> None:
86
+ self._paths = [Path(path)] if path else _default_window_cache_paths()
87
+ self.path = self._paths[0]
88
+ self.last_error = ""
89
+
90
+ def get(self, client: RemoteCliClient, session: str, app_query: str) -> WindowRef | None:
91
+ session_data = self._session_data(client, session)
92
+ apps = session_data.get("apps") if isinstance(session_data, dict) else None
93
+ if not isinstance(apps, dict):
94
+ return None
95
+ value = apps.get(_normalize_cache_key(app_query))
96
+ if not isinstance(value, dict):
97
+ return None
98
+
99
+ pid = _coerce_int(value.get("pid"))
100
+ window_id = _coerce_int(value.get("window_id"))
101
+ if not pid or not window_id:
102
+ return None
103
+
104
+ app = value.get("app") if isinstance(value.get("app"), dict) else {}
105
+ window = value.get("window") if isinstance(value.get("window"), dict) else {}
106
+ return WindowRef(
107
+ pid=pid,
108
+ window_id=window_id,
109
+ title=str(value.get("title") or window.get("title") or ""),
110
+ app=app,
111
+ window=window,
112
+ cached=True,
113
+ )
114
+
115
+ def put(
116
+ self,
117
+ client: RemoteCliClient,
118
+ session: str,
119
+ app_query: str,
120
+ ref: WindowRef,
121
+ ) -> None:
122
+ data = self._read()
123
+ sessions = data.setdefault("sessions", {})
124
+ if not isinstance(sessions, dict):
125
+ sessions = {}
126
+ data["sessions"] = sessions
127
+ session_key = _session_cache_key(client, session)
128
+ session_data = sessions.setdefault(
129
+ session_key,
130
+ {
131
+ "session": session,
132
+ "middleware_url": str(getattr(client, "middleware_url", "")),
133
+ "tenant_id": str(getattr(client, "tenant_id", "")),
134
+ "node_id": str(getattr(client, "node_id", "")),
135
+ "apps": {},
136
+ },
137
+ )
138
+ if not isinstance(session_data, dict):
139
+ session_data = {"session": session, "apps": {}}
140
+ sessions[session_key] = session_data
141
+ apps = session_data.setdefault("apps", {})
142
+ if not isinstance(apps, dict):
143
+ apps = {}
144
+ session_data["apps"] = apps
145
+
146
+ now = time.time()
147
+ session_data["updated_at"] = now
148
+ apps[_normalize_cache_key(app_query)] = {
149
+ "pid": ref.pid,
150
+ "window_id": ref.window_id,
151
+ "title": ref.title,
152
+ "app": ref.app,
153
+ "window": ref.window,
154
+ "updated_at": now,
155
+ }
156
+ try:
157
+ self._write(data)
158
+ except OSError as exc:
159
+ self.last_error = str(exc)
160
+
161
+ def remove_app(self, client: RemoteCliClient, session: str, app_query: str) -> None:
162
+ data = self._read()
163
+ session_data = self._session_data(client, session, data=data)
164
+ apps = session_data.get("apps") if isinstance(session_data, dict) else None
165
+ if isinstance(apps, dict):
166
+ apps.pop(_normalize_cache_key(app_query), None)
167
+ try:
168
+ self._write(data)
169
+ except OSError as exc:
170
+ self.last_error = str(exc)
171
+
172
+ def clear_session(self, client: RemoteCliClient, session: str) -> None:
173
+ data = self._read()
174
+ sessions = data.get("sessions")
175
+ if isinstance(sessions, dict):
176
+ sessions.pop(_session_cache_key(client, session), None)
177
+ try:
178
+ self._write(data)
179
+ except OSError as exc:
180
+ self.last_error = str(exc)
181
+
182
+ def _session_data(
183
+ self,
184
+ client: RemoteCliClient,
185
+ session: str,
186
+ data: Dict[str, Any] | None = None,
187
+ ) -> Dict[str, Any]:
188
+ data = data if data is not None else self._read()
189
+ sessions = data.get("sessions")
190
+ if not isinstance(sessions, dict):
191
+ return {}
192
+ value = sessions.get(_session_cache_key(client, session))
193
+ return value if isinstance(value, dict) else {}
194
+
195
+ def _read(self) -> Dict[str, Any]:
196
+ data: Dict[str, Any] = {"version": 1, "sessions": {}}
197
+ for path in self._read_paths():
198
+ _merge_window_cache_data(data, self._read_path(path))
199
+ return data
200
+
201
+ def _read_paths(self) -> list[Path]:
202
+ return self._paths
203
+
204
+ def _read_path(self, path: Path) -> Dict[str, Any]:
205
+ try:
206
+ if not path.exists():
207
+ return {"version": 1, "sessions": {}}
208
+ loaded = json.loads(path.read_text(encoding="utf-8"))
209
+ except (OSError, json.JSONDecodeError):
210
+ return {"version": 1, "sessions": {}}
211
+ if not isinstance(loaded, dict):
212
+ return {"version": 1, "sessions": {}}
213
+ data = dict(loaded)
214
+ data.setdefault("sessions", {})
215
+ return data
216
+
217
+ def _write(self, data: Dict[str, Any]) -> None:
218
+ errors: list[str] = []
219
+ for path in self._paths:
220
+ try:
221
+ self._write_to_path(path, data)
222
+ except OSError as exc:
223
+ errors.append(f"{path}: {exc}")
224
+ continue
225
+ self.path = path
226
+ self.last_error = ""
227
+ return
228
+ raise OSError("; ".join(errors) or "no writable window cache path")
229
+
230
+ @staticmethod
231
+ def _write_to_path(path: Path, data: Dict[str, Any]) -> None:
232
+ path.parent.mkdir(parents=True, exist_ok=True)
233
+ path.write_text(
234
+ json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True),
235
+ encoding="utf-8",
236
+ )
237
+
238
+
239
+ def _merge_window_cache_data(target: Dict[str, Any], source: Dict[str, Any]) -> None:
240
+ source_sessions = source.get("sessions")
241
+ if not isinstance(source_sessions, dict):
242
+ return
243
+
244
+ target_sessions = target.setdefault("sessions", {})
245
+ if not isinstance(target_sessions, dict):
246
+ target_sessions = {}
247
+ target["sessions"] = target_sessions
248
+
249
+ for session_key, source_session in source_sessions.items():
250
+ if not isinstance(source_session, dict):
251
+ continue
252
+ target_session = target_sessions.get(session_key)
253
+ if not isinstance(target_session, dict):
254
+ target_sessions[session_key] = source_session
255
+ continue
256
+
257
+ if _coerce_float(source_session.get("updated_at")) >= _coerce_float(target_session.get("updated_at")):
258
+ for key, value in source_session.items():
259
+ if key != "apps":
260
+ target_session[key] = value
261
+
262
+ source_apps = source_session.get("apps")
263
+ if not isinstance(source_apps, dict):
264
+ continue
265
+ target_apps = target_session.setdefault("apps", {})
266
+ if not isinstance(target_apps, dict):
267
+ target_apps = {}
268
+ target_session["apps"] = target_apps
269
+ for app_key, source_app in source_apps.items():
270
+ target_app = target_apps.get(app_key)
271
+ if not isinstance(target_app, dict) or _coerce_float(source_app.get("updated_at")) >= _coerce_float(
272
+ target_app.get("updated_at")
273
+ ):
274
+ target_apps[app_key] = source_app
@@ -0,0 +1,5 @@
1
+ """Mode constants for high-level agent wrappers."""
2
+
3
+ _OBSERVE_MODES = {"uia", "vision", "som-full"}
4
+ _PROBE_MODES = {"smart", "uia", "som"}
5
+ _LOCATE_MODES = {"smart", "uia", "som"}
@@ -0,0 +1,323 @@
1
+ """Optional fast app-open helper for agent commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict
6
+
7
+ from .agent_cache import WindowCache, WindowRef
8
+ from .agent_result import _error
9
+ from .agent_utils import _extract_int
10
+ from .client import RemoteCliClient
11
+ from .shell import ShellCommands
12
+
13
+
14
+ WaitAndUnpack = Callable[[Dict[str, Any], str, float, float], Dict[str, Any]]
15
+
16
+ _APP_ALIASES: dict[str, tuple[str, ...]] = {
17
+ "firefox": ("firefox", "mozilla firefox", "火狐", "火狐浏览器"),
18
+ "chrome": ("chrome", "google chrome", "谷歌", "谷歌浏览器"),
19
+ "edge": ("edge", "microsoft edge", "msedge", "微软浏览器", "edge浏览器"),
20
+ "wechat": ("wechat", "weixin", "微信"),
21
+ "wecom": ("wecom", "wxwork", "企业微信", "企微"),
22
+ "notepad": ("notepad", "notepad.exe", "记事本"),
23
+ }
24
+
25
+
26
+ def open_app_window(
27
+ client: RemoteCliClient,
28
+ shell: ShellCommands,
29
+ window_cache: WindowCache,
30
+ wait_and_unpack: WaitAndUnpack,
31
+ app_query: str,
32
+ *,
33
+ session_id: str = "",
34
+ use_window_cache: bool = True,
35
+ launch_path: str = "",
36
+ app_args: str = "",
37
+ start_if_missing: bool = True,
38
+ foreground: bool = True,
39
+ launch_timeout: int = 15,
40
+ wait_timeout: float = 30.0,
41
+ poll_interval: float = 0.1,
42
+ ) -> Dict[str, Any]:
43
+ app_query = str(app_query or "").strip()
44
+ if not app_query:
45
+ return _error("open", "app query is required")
46
+
47
+ script = build_open_app_script(
48
+ app_query,
49
+ launch_path=launch_path,
50
+ app_args=app_args,
51
+ start_if_missing=start_if_missing,
52
+ foreground=foreground,
53
+ launch_timeout=launch_timeout,
54
+ )
55
+ result = wait_and_unpack(
56
+ shell.exec_script(script, shell="powershell", timeout_sec=max(60, launch_timeout + 10)),
57
+ "open",
58
+ wait_timeout,
59
+ poll_interval,
60
+ )
61
+ payload = result.get("json") if isinstance(result.get("json"), dict) else {}
62
+ ok = bool(result.get("ok")) and bool(payload.get("ok", True))
63
+ pid = _extract_int(payload, "pid", "process_id", "processId", "id")
64
+ window_id = _extract_int(payload, "window_id", "windowId", "hwnd", "handle")
65
+ title = str(payload.get("title") or payload.get("MainWindowTitle") or "")
66
+
67
+ if not ok or not pid or not window_id:
68
+ return {
69
+ "ok": False,
70
+ "action": "open",
71
+ "app": app_query,
72
+ "pid": pid,
73
+ "window_id": window_id,
74
+ "title": title,
75
+ "started": bool(payload.get("started")),
76
+ "source": str(payload.get("source") or ""),
77
+ "error": str(payload.get("error") or result.get("error") or "open did not return a usable window"),
78
+ "result": result,
79
+ **_cache_context(window_cache),
80
+ }
81
+
82
+ app = {
83
+ "name": app_query,
84
+ "pid": pid,
85
+ "running": True,
86
+ "source": payload.get("source") or "shell_open",
87
+ "launch_target": payload.get("launch_target") or "",
88
+ }
89
+ window = {
90
+ "window_id": window_id,
91
+ "title": title,
92
+ "pid": pid,
93
+ "source": payload.get("source") or "shell_open",
94
+ }
95
+ ref = WindowRef(pid=pid, window_id=window_id, title=title, app=app, window=window)
96
+ if use_window_cache and session_id:
97
+ window_cache.put(client, session_id, app_query, ref)
98
+
99
+ return {
100
+ "ok": True,
101
+ "action": "open",
102
+ "app": app_query,
103
+ "started": bool(payload.get("started")),
104
+ "pid": pid,
105
+ "window_id": window_id,
106
+ "title": title,
107
+ "source": str(payload.get("source") or "shell_open"),
108
+ "launch_target": str(payload.get("launch_target") or ""),
109
+ "window_cached": False,
110
+ "result": result,
111
+ **_cache_context(window_cache),
112
+ }
113
+
114
+
115
+ def build_open_app_script(
116
+ app_query: str,
117
+ *,
118
+ launch_path: str = "",
119
+ app_args: str = "",
120
+ start_if_missing: bool = True,
121
+ foreground: bool = True,
122
+ launch_timeout: int = 15,
123
+ ) -> str:
124
+ terms = _search_terms(app_query)
125
+ terms_literal = ", ".join(_ps_quote(term) for term in terms)
126
+ return f"""
127
+ $ErrorActionPreference = "SilentlyContinue"
128
+ $query = {_ps_quote(app_query)}
129
+ $launchPath = {_ps_quote(launch_path)}
130
+ $appArgs = {_ps_quote(app_args)}
131
+ $startIfMissing = {_ps_bool(start_if_missing)}
132
+ $foreground = {_ps_bool(foreground)}
133
+ $launchTimeoutSec = {max(1, int(launch_timeout))}
134
+ $terms = @({terms_literal})
135
+
136
+ function Test-AgentRemoteTextMatch($value) {{
137
+ $text = [string]$value
138
+ if ([string]::IsNullOrWhiteSpace($text)) {{ return $false }}
139
+ $lower = $text.ToLowerInvariant()
140
+ foreach ($term in $terms) {{
141
+ if ($lower.Contains(([string]$term).ToLowerInvariant())) {{ return $true }}
142
+ }}
143
+ return $false
144
+ }}
145
+
146
+ function Test-AgentRemoteProcessMatch($proc) {{
147
+ if (-not $proc) {{ return $false }}
148
+ if (Test-AgentRemoteTextMatch $proc.ProcessName) {{ return $true }}
149
+ if (Test-AgentRemoteTextMatch $proc.MainWindowTitle) {{ return $true }}
150
+ try {{
151
+ if (Test-AgentRemoteTextMatch $proc.Path) {{ return $true }}
152
+ }} catch {{}}
153
+ return $false
154
+ }}
155
+
156
+ function Find-AgentRemoteWindow() {{
157
+ Get-Process |
158
+ Where-Object {{
159
+ $_.MainWindowTitle -ne "" -and
160
+ [int64]$_.MainWindowHandle -ne 0 -and
161
+ (Test-AgentRemoteProcessMatch $_)
162
+ }} |
163
+ Select-Object -First 1
164
+ }}
165
+
166
+ function Set-AgentRemoteForeground($hwnd) {{
167
+ if (-not $foreground -or -not $hwnd -or [int64]$hwnd -eq 0) {{ return $false }}
168
+ try {{
169
+ $type = @"
170
+ using System;
171
+ using System.Runtime.InteropServices;
172
+ public static class AgentRemoteUser32 {{
173
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
174
+ [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
175
+ }}
176
+ "@
177
+ Add-Type -TypeDefinition $type -ErrorAction SilentlyContinue | Out-Null
178
+ [AgentRemoteUser32]::ShowWindowAsync([IntPtr][int64]$hwnd, 9) | Out-Null
179
+ return [AgentRemoteUser32]::SetForegroundWindow([IntPtr][int64]$hwnd)
180
+ }} catch {{
181
+ return $false
182
+ }}
183
+ }}
184
+
185
+ function Find-AgentRemoteStartApp() {{
186
+ Get-StartApps |
187
+ Where-Object {{
188
+ Test-AgentRemoteTextMatch $_.Name -or Test-AgentRemoteTextMatch $_.AppID
189
+ }} |
190
+ Select-Object -First 1
191
+ }}
192
+
193
+ function Find-AgentRemoteCommand() {{
194
+ foreach ($term in $terms) {{
195
+ $clean = ([string]$term).Trim()
196
+ if (-not $clean -or $clean.Contains(" ")) {{ continue }}
197
+ $cmd = Get-Command ($clean + ".exe") -ErrorAction SilentlyContinue | Select-Object -First 1
198
+ if ($cmd) {{ return $cmd }}
199
+ $cmd = Get-Command $clean -ErrorAction SilentlyContinue | Select-Object -First 1
200
+ if ($cmd) {{ return $cmd }}
201
+ }}
202
+ return $null
203
+ }}
204
+
205
+ $started = $false
206
+ $source = "running_process"
207
+ $launchTarget = ""
208
+ $proc = Find-AgentRemoteWindow
209
+
210
+ if (-not $proc) {{
211
+ if (-not $startIfMissing) {{
212
+ [pscustomobject]@{{ ok=$false; app=$query; started=$false; error="window not found"; source="not_started" }} |
213
+ ConvertTo-Json -Compress
214
+ exit 0
215
+ }}
216
+
217
+ if ($launchPath) {{
218
+ if (Test-Path -LiteralPath $launchPath) {{
219
+ if ($appArgs) {{
220
+ Start-Process -FilePath $launchPath -ArgumentList $appArgs
221
+ }} else {{
222
+ Start-Process -FilePath $launchPath
223
+ }}
224
+ $started = $true
225
+ $source = "path"
226
+ $launchTarget = $launchPath
227
+ }}
228
+ }}
229
+
230
+ if (-not $started) {{
231
+ $startApp = Find-AgentRemoteStartApp
232
+ if ($startApp) {{
233
+ $launchTarget = "shell:AppsFolder\\" + $startApp.AppID
234
+ if ($appArgs) {{
235
+ Start-Process $launchTarget -ArgumentList $appArgs
236
+ }} else {{
237
+ Start-Process $launchTarget
238
+ }}
239
+ $started = $true
240
+ $source = "start_apps"
241
+ }}
242
+ }}
243
+
244
+ if (-not $started) {{
245
+ $cmd = Find-AgentRemoteCommand
246
+ if ($cmd) {{
247
+ if ($appArgs) {{
248
+ Start-Process -FilePath $cmd.Source -ArgumentList $appArgs
249
+ }} else {{
250
+ Start-Process -FilePath $cmd.Source
251
+ }}
252
+ $started = $true
253
+ $source = "command"
254
+ $launchTarget = $cmd.Source
255
+ }}
256
+ }}
257
+
258
+ if (-not $started) {{
259
+ [pscustomobject]@{{ ok=$false; app=$query; started=$false; error="app launch target not found"; source="not_found" }} |
260
+ ConvertTo-Json -Compress
261
+ exit 0
262
+ }}
263
+
264
+ $deadline = (Get-Date).AddSeconds($launchTimeoutSec)
265
+ do {{
266
+ Start-Sleep -Milliseconds 100
267
+ $proc = Find-AgentRemoteWindow
268
+ }} while (-not $proc -and (Get-Date) -lt $deadline)
269
+ }}
270
+
271
+ if (-not $proc) {{
272
+ [pscustomobject]@{{ ok=$false; app=$query; started=$started; error="window not found after launch"; source=$source; launch_target=$launchTarget }} |
273
+ ConvertTo-Json -Compress
274
+ exit 0
275
+ }}
276
+
277
+ $foregrounded = Set-AgentRemoteForeground $proc.MainWindowHandle
278
+ [pscustomobject]@{{
279
+ ok = $true
280
+ app = $query
281
+ started = $started
282
+ pid = [int]$proc.Id
283
+ window_id = [int64]$proc.MainWindowHandle
284
+ title = [string]$proc.MainWindowTitle
285
+ process = [string]$proc.ProcessName
286
+ source = $source
287
+ launch_target = $launchTarget
288
+ foregrounded = [bool]$foregrounded
289
+ }} | ConvertTo-Json -Compress
290
+ """.strip()
291
+
292
+
293
+ def _search_terms(app_query: str) -> list[str]:
294
+ query = " ".join(str(app_query or "").strip().split())
295
+ lowered = query.casefold()
296
+ terms: list[str] = []
297
+
298
+ def add(value: str) -> None:
299
+ text = " ".join(str(value or "").strip().split())
300
+ if text and text not in terms:
301
+ terms.append(text)
302
+
303
+ add(query)
304
+ for aliases in _APP_ALIASES.values():
305
+ normalized_aliases = [alias.casefold() for alias in aliases]
306
+ if any(lowered == alias or lowered in alias or alias in lowered for alias in normalized_aliases):
307
+ for alias in aliases:
308
+ add(alias)
309
+ return terms
310
+
311
+
312
+ def _ps_quote(value: Any) -> str:
313
+ return "'" + str(value or "").replace("'", "''") + "'"
314
+
315
+
316
+ def _ps_bool(value: bool) -> str:
317
+ return "$true" if value else "$false"
318
+
319
+
320
+ def _cache_context(window_cache: WindowCache) -> Dict[str, Any]:
321
+ if not window_cache.last_error:
322
+ return {}
323
+ return {"window_cache_error": window_cache.last_error}