seedcode-cli 6.1.5__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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Screen Intelligence Engine: structured desktop state with semantic IDs.
|
|
2
|
+
|
|
3
|
+
The engine answers "what is on screen" as *structured JSON*, not pixels:
|
|
4
|
+
UI Automation is the primary source (via the existing :mod:`.vision`
|
|
5
|
+
walker), window metadata secondary, OCR/screenshot only ever a fallback
|
|
6
|
+
owned elsewhere. The AI never receives — and never supplies — raw
|
|
7
|
+
coordinates for normal interaction; it reasons over stable element ids:
|
|
8
|
+
|
|
9
|
+
find_ui_element("Play") -> {"id": "element_042", "role": "button", ...}
|
|
10
|
+
click("element_042") -> engine resolves the id, re-validates
|
|
11
|
+
freshness against the live tree, then acts
|
|
12
|
+
|
|
13
|
+
Design points the tests pin:
|
|
14
|
+
|
|
15
|
+
* **Semantic ids are session-stable.** ``element_042`` keeps pointing at the
|
|
16
|
+
same element across queries as long as the underlying snapshot is valid;
|
|
17
|
+
ids are assigned deterministically per snapshot generation.
|
|
18
|
+
* **Freshness validation.** Acting on a cached element re-reads the live
|
|
19
|
+
tree and re-locates the element by (role, name, automation_id). If it is
|
|
20
|
+
gone or moved, :class:`StaleElementError` is raised — never a blind click
|
|
21
|
+
at an old coordinate.
|
|
22
|
+
* **Caching with change hashes.** ``window_hash`` / ``element_hash`` detect
|
|
23
|
+
change; unchanged desktops reuse cached state instead of rescanning.
|
|
24
|
+
Scans are FULL, INCREMENTAL (windows only), or TARGETED (one element).
|
|
25
|
+
* **Targeted queries.** ``find_ui_element`` returns the match (with nearby
|
|
26
|
+
context), not the whole tree.
|
|
27
|
+
|
|
28
|
+
All drivers are injectable, so the engine is fully unit-testable without a
|
|
29
|
+
desktop. Windows-only at runtime; importing is safe everywhere.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from ..core.errors import ElementNotFoundError, ScreenUnavailableError, StaleElementError
|
|
39
|
+
from ..core.limits import MAX_WAIT_ELEMENT_S, MAX_WAIT_POLL_S, clamp_wait
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- models ---------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class Element:
|
|
46
|
+
"""One UI element with a stable semantic id and full metadata."""
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
role: str
|
|
50
|
+
name: str
|
|
51
|
+
x: int # center, physical screen space
|
|
52
|
+
y: int
|
|
53
|
+
width: int
|
|
54
|
+
height: int
|
|
55
|
+
enabled: bool = True
|
|
56
|
+
value: str = ""
|
|
57
|
+
automation_id: str = ""
|
|
58
|
+
control_type: str = "" # raw UIA type ("ButtonControl"), when known
|
|
59
|
+
# State flags where the source exposes them.
|
|
60
|
+
focused: bool = False
|
|
61
|
+
selected: bool = False
|
|
62
|
+
checked: bool = False
|
|
63
|
+
# Element ids are only guaranteed fresh within the snapshot generation
|
|
64
|
+
# they were issued in; actions re-validate.
|
|
65
|
+
generation: int = 0
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def center(self) -> tuple[int, int]:
|
|
69
|
+
return (self.x, self.y)
|
|
70
|
+
|
|
71
|
+
def bounds(self) -> list[int]:
|
|
72
|
+
return [self.x - self.width // 2, self.y - self.height // 2,
|
|
73
|
+
self.width, self.height]
|
|
74
|
+
|
|
75
|
+
def describe(self) -> str:
|
|
76
|
+
state = "" if self.enabled else " (disabled)"
|
|
77
|
+
return f'{self.role} "{self.name or "(unnamed)"}"{state}'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(slots=True)
|
|
81
|
+
class WindowRef:
|
|
82
|
+
"""One top-level window in engine form."""
|
|
83
|
+
|
|
84
|
+
id: str
|
|
85
|
+
title: str
|
|
86
|
+
pid: int = 0
|
|
87
|
+
focused: bool = False
|
|
88
|
+
minimized: bool = False
|
|
89
|
+
bounds: list[int] = field(default_factory=lambda: [0, 0, 0, 0]) # l,t,w,h
|
|
90
|
+
|
|
91
|
+
def describe(self) -> str:
|
|
92
|
+
mark = " [focused]" if self.focused else (" [minimized]" if self.minimized else "")
|
|
93
|
+
return f'"{self.title}"{mark} at ({self.bounds[0]}, {self.bounds[1]}) size {self.bounds[2]}x{self.bounds[3]}'
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(slots=True)
|
|
97
|
+
class ScreenSnapshot:
|
|
98
|
+
"""A generation of screen state (windows + elements) plus change hashes."""
|
|
99
|
+
|
|
100
|
+
generation: int
|
|
101
|
+
created_at: float
|
|
102
|
+
windows: list[WindowRef] = field(default_factory=list)
|
|
103
|
+
elements: list[Element] = field(default_factory=list)
|
|
104
|
+
active_window: WindowRef | None = None
|
|
105
|
+
window_hash: str = ""
|
|
106
|
+
element_hash: str = ""
|
|
107
|
+
|
|
108
|
+
def window_by_title(self, fragment: str) -> WindowRef | None:
|
|
109
|
+
low = (fragment or "").strip().lower()
|
|
110
|
+
for w in self.windows:
|
|
111
|
+
if low and low in w.title.lower():
|
|
112
|
+
return w
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --- the engine -------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
class ScreenEngine:
|
|
119
|
+
"""Structured screen state: cache, semantic ids, targeted queries.
|
|
120
|
+
|
|
121
|
+
``vision`` and ``windows`` are the existing drivers (injectable for
|
|
122
|
+
tests). A generation counter invalidates ids when the desktop changes;
|
|
123
|
+
the cache avoids rescanning an unchanged desktop.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, vision: Any = None, windows: Any = None,
|
|
127
|
+
max_elements: int = 150) -> None:
|
|
128
|
+
if vision is None:
|
|
129
|
+
from . import vision as vision # type: ignore
|
|
130
|
+
if windows is None:
|
|
131
|
+
from . import windows as windows # type: ignore
|
|
132
|
+
self._vision = vision
|
|
133
|
+
self._windows = windows
|
|
134
|
+
self._max_elements = max_elements
|
|
135
|
+
self._generation = 0
|
|
136
|
+
self._cache: ScreenSnapshot | None = None
|
|
137
|
+
# element_id -> Element for the current generation.
|
|
138
|
+
self._by_id: dict[str, Element] = {}
|
|
139
|
+
|
|
140
|
+
# --- availability -----------------------------------------------------------
|
|
141
|
+
def _read_windows(self) -> tuple[list[WindowRef], WindowRef | None]:
|
|
142
|
+
"""Best-effort window enumeration; empty on failure (no desktop)."""
|
|
143
|
+
try:
|
|
144
|
+
raw = self._windows.list_windows()
|
|
145
|
+
except Exception:
|
|
146
|
+
return [], None
|
|
147
|
+
refs: list[WindowRef] = []
|
|
148
|
+
for i, w in enumerate(raw, start=1):
|
|
149
|
+
refs.append(
|
|
150
|
+
WindowRef(
|
|
151
|
+
id=f"window_{i:03d}",
|
|
152
|
+
title=getattr(w, "title", "") or "",
|
|
153
|
+
pid=int(getattr(w, "pid", 0) or 0),
|
|
154
|
+
focused=bool(getattr(w, "active", False)),
|
|
155
|
+
minimized=bool(getattr(w, "minimized", False)),
|
|
156
|
+
bounds=[int(getattr(w, "left", 0)), int(getattr(w, "top", 0)),
|
|
157
|
+
int(getattr(w, "width", 0)), int(getattr(w, "height", 0))],
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
active = next((r for r in refs if r.focused), None)
|
|
161
|
+
return refs, active
|
|
162
|
+
|
|
163
|
+
def _read_elements(self, window_title: str | None) -> list[tuple[Any, ...]]:
|
|
164
|
+
"""Raw element tuples from the vision driver ([] when unavailable)."""
|
|
165
|
+
try:
|
|
166
|
+
_title, elements = self._vision.snapshot(window_title)
|
|
167
|
+
except Exception:
|
|
168
|
+
return []
|
|
169
|
+
return [(e, getattr(e, "role", ""), getattr(e, "name", ""),
|
|
170
|
+
getattr(e, "x", 0), getattr(e, "y", 0),
|
|
171
|
+
getattr(e, "width", 0), getattr(e, "height", 0),
|
|
172
|
+
bool(getattr(e, "enabled", True)),
|
|
173
|
+
getattr(e, "automation_id", "") or "",
|
|
174
|
+
getattr(e, "value", "") or "") for e in (elements or [])]
|
|
175
|
+
|
|
176
|
+
# --- hashing ---------------------------------------------------------------
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _hash_parts(parts: list[str]) -> str:
|
|
179
|
+
import hashlib
|
|
180
|
+
|
|
181
|
+
return hashlib.sha1("\x1f".join(parts).encode("utf-8", "replace")).hexdigest()[:16]
|
|
182
|
+
|
|
183
|
+
def _window_hash(self, wins: list[WindowRef]) -> str:
|
|
184
|
+
return self._hash_parts(
|
|
185
|
+
[f"{w.title}|{w.bounds}|{w.focused}|{w.minimized}" for w in wins]
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _element_hash(self, raw: list[tuple[Any, ...]]) -> str:
|
|
189
|
+
return self._hash_parts(
|
|
190
|
+
[f"{r[1]}|{r[2]}|{r[3]}|{r[4]}|{r[7]}" for r in raw]
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# --- scans -------------------------------------------------------------------
|
|
194
|
+
def refresh(self, mode: str = "full", window_title: str | None = None) -> ScreenSnapshot:
|
|
195
|
+
"""Re-read screen state.
|
|
196
|
+
|
|
197
|
+
Modes: ``full`` (windows + elements), ``incremental`` (windows only —
|
|
198
|
+
cached elements kept), ``targeted`` (validate one element; see
|
|
199
|
+
:meth:`locate`).
|
|
200
|
+
"""
|
|
201
|
+
mode = (mode or "full").strip().lower()
|
|
202
|
+
wins, active = self._read_windows()
|
|
203
|
+
if mode == "incremental" and self._cache is not None:
|
|
204
|
+
w_hash = self._window_hash(wins)
|
|
205
|
+
if w_hash == self._cache.window_hash:
|
|
206
|
+
return self._cache # nothing changed at window level
|
|
207
|
+
self._generation += 1
|
|
208
|
+
snap = ScreenSnapshot(
|
|
209
|
+
generation=self._generation, created_at=time.time(),
|
|
210
|
+
windows=wins, active_window=active,
|
|
211
|
+
window_hash=w_hash,
|
|
212
|
+
element_hash=self._cache.element_hash,
|
|
213
|
+
)
|
|
214
|
+
self._cache = snap
|
|
215
|
+
return snap
|
|
216
|
+
|
|
217
|
+
raw = self._read_elements(window_title)
|
|
218
|
+
w_hash = self._window_hash(wins)
|
|
219
|
+
e_hash = self._element_hash(raw)
|
|
220
|
+
if (
|
|
221
|
+
mode != "targeted"
|
|
222
|
+
and self._cache is not None
|
|
223
|
+
and self._cache.window_hash == w_hash
|
|
224
|
+
and self._cache.element_hash == e_hash
|
|
225
|
+
):
|
|
226
|
+
# Identical desktop: keep the same generation (ids stay valid).
|
|
227
|
+
self._cache.created_at = time.time()
|
|
228
|
+
return self._cache
|
|
229
|
+
|
|
230
|
+
self._generation += 1
|
|
231
|
+
snap = ScreenSnapshot(
|
|
232
|
+
generation=self._generation, created_at=time.time(),
|
|
233
|
+
windows=wins, active_window=active,
|
|
234
|
+
window_hash=w_hash, element_hash=e_hash,
|
|
235
|
+
)
|
|
236
|
+
self._by_id = {}
|
|
237
|
+
for i, r in enumerate(raw[: self._max_elements], start=1):
|
|
238
|
+
el = Element(
|
|
239
|
+
id=f"element_{i:03d}",
|
|
240
|
+
role=str(r[1] or "element"),
|
|
241
|
+
name=str(r[2] or ""),
|
|
242
|
+
x=int(r[3]), y=int(r[4]), width=int(r[5]), height=int(r[6]),
|
|
243
|
+
enabled=bool(r[7]), automation_id=str(r[8]), value=str(r[9]),
|
|
244
|
+
control_type=str(getattr(r[0], "control_type", "") or getattr(r[0], "_control_type", "") or ""),
|
|
245
|
+
generation=self._generation,
|
|
246
|
+
)
|
|
247
|
+
snap.elements.append(el)
|
|
248
|
+
self._by_id[el.id] = el
|
|
249
|
+
self._cache = snap
|
|
250
|
+
return snap
|
|
251
|
+
|
|
252
|
+
# --- targeted queries --------------------------------------------------------
|
|
253
|
+
def _match_score(self, query: str, el: Element) -> float:
|
|
254
|
+
"""Cheap containment/role scoring (fuzzy matching lives in the resolver)."""
|
|
255
|
+
q = (query or "").strip().lower()
|
|
256
|
+
if not q:
|
|
257
|
+
return 0.0
|
|
258
|
+
name = (el.name or "").lower()
|
|
259
|
+
aid = (el.automation_id or "").lower()
|
|
260
|
+
score = 0.0
|
|
261
|
+
if q in name:
|
|
262
|
+
score = 300.0
|
|
263
|
+
elif name and name in q:
|
|
264
|
+
score = 200.0
|
|
265
|
+
elif q in aid:
|
|
266
|
+
score = 280.0
|
|
267
|
+
# A trailing role hint ("play button") is a plus, not a requirement.
|
|
268
|
+
if any(hint in q for hint in (el.role, "button" if el.role == "button" else el.role)):
|
|
269
|
+
score += 20.0
|
|
270
|
+
if not el.enabled:
|
|
271
|
+
score -= 80.0
|
|
272
|
+
return score
|
|
273
|
+
|
|
274
|
+
def find_element(self, query: str, *, window: str | None = None,
|
|
275
|
+
fresh: bool = True) -> Element:
|
|
276
|
+
"""Targeted query: one element by name/role/automation-id.
|
|
277
|
+
|
|
278
|
+
``fresh=True`` (the default) always re-reads the live tree so ids are
|
|
279
|
+
minted against current reality — the AI cannot act on a stale picture.
|
|
280
|
+
Raises :class:`ElementNotFoundError` when nothing matches.
|
|
281
|
+
"""
|
|
282
|
+
if fresh:
|
|
283
|
+
snap = self.refresh(mode="full", window_title=window)
|
|
284
|
+
else:
|
|
285
|
+
snap = self._cache or self.refresh(mode="full", window_title=window)
|
|
286
|
+
best: tuple[float, Element] | None = None
|
|
287
|
+
for el in snap.elements:
|
|
288
|
+
s = self._match_score(query, el)
|
|
289
|
+
if s > 0 and (best is None or s > best[0]):
|
|
290
|
+
best = (s, el)
|
|
291
|
+
if best is None or best[0] <= 0:
|
|
292
|
+
raise ElementNotFoundError(
|
|
293
|
+
f'No element matching "{query}" on screen. '
|
|
294
|
+
"Try a different description or check the window is open."
|
|
295
|
+
)
|
|
296
|
+
return best[1]
|
|
297
|
+
|
|
298
|
+
def get_element(self, element_id: str) -> Element:
|
|
299
|
+
"""Fetch a cached element by id, validating freshness.
|
|
300
|
+
|
|
301
|
+
The id must exist in the cached generation AND still resolve in a
|
|
302
|
+
fresh scan by (role, name, automation_id) — otherwise the UI changed
|
|
303
|
+
and :class:`StaleElementError` tells the planner to re-query.
|
|
304
|
+
"""
|
|
305
|
+
el = self._by_id.get((element_id or "").strip().lower())
|
|
306
|
+
if el is None:
|
|
307
|
+
if self._by_id:
|
|
308
|
+
raise StaleElementError(element_id or "")
|
|
309
|
+
# Cold cache (id quoted from a previous process/query): a scan is
|
|
310
|
+
# required before anything can be declared stale.
|
|
311
|
+
self.refresh(mode="full")
|
|
312
|
+
el = self._by_id.get((element_id or "").strip().lower())
|
|
313
|
+
if el is None:
|
|
314
|
+
raise StaleElementError(element_id or "")
|
|
315
|
+
# Freshness: re-read and re-locate by identity triple.
|
|
316
|
+
snap = self.refresh(mode="full")
|
|
317
|
+
for candidate in snap.elements:
|
|
318
|
+
if (
|
|
319
|
+
candidate.role == el.role
|
|
320
|
+
and candidate.name == el.name
|
|
321
|
+
and candidate.automation_id == el.automation_id
|
|
322
|
+
):
|
|
323
|
+
# Live position wins: return the *fresh* element, keep the id
|
|
324
|
+
# stable so the caller's reference remains meaningful.
|
|
325
|
+
fresh = Element(
|
|
326
|
+
id=el.id, role=candidate.role, name=candidate.name,
|
|
327
|
+
x=candidate.x, y=candidate.y, width=candidate.width,
|
|
328
|
+
height=candidate.height, enabled=candidate.enabled,
|
|
329
|
+
value=candidate.value, automation_id=candidate.automation_id,
|
|
330
|
+
control_type=candidate.control_type,
|
|
331
|
+
focused=candidate.focused, selected=candidate.selected,
|
|
332
|
+
checked=candidate.checked, generation=snap.generation,
|
|
333
|
+
)
|
|
334
|
+
self._by_id[el.id] = fresh
|
|
335
|
+
return fresh
|
|
336
|
+
raise StaleElementError(el.id)
|
|
337
|
+
|
|
338
|
+
def active_window(self) -> WindowRef | None:
|
|
339
|
+
snap = self.refresh(mode="incremental")
|
|
340
|
+
return snap.active_window
|
|
341
|
+
|
|
342
|
+
def list_windows(self) -> list[WindowRef]:
|
|
343
|
+
return self.refresh(mode="incremental").windows
|
|
344
|
+
|
|
345
|
+
# --- serialization -------------------------------------------------------------
|
|
346
|
+
def state_json(self, include_elements: bool = True) -> dict[str, Any]:
|
|
347
|
+
"""The model-facing structured state (compact, JSON-safe)."""
|
|
348
|
+
snap = self.refresh(mode="incremental")
|
|
349
|
+
state: dict[str, Any] = {
|
|
350
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(snap.created_at)),
|
|
351
|
+
"generation": snap.generation,
|
|
352
|
+
"active_window": (
|
|
353
|
+
{"id": snap.active_window.id, "app": _app_from_title(snap.active_window.title),
|
|
354
|
+
"title": snap.active_window.title, "pid": snap.active_window.pid,
|
|
355
|
+
"bounds": snap.active_window.bounds}
|
|
356
|
+
if snap.active_window else None
|
|
357
|
+
),
|
|
358
|
+
"windows": [
|
|
359
|
+
{"id": w.id, "app": _app_from_title(w.title), "title": w.title,
|
|
360
|
+
"pid": w.pid, "visible": not w.minimized, "focused": w.focused,
|
|
361
|
+
"bounds": w.bounds}
|
|
362
|
+
for w in snap.windows
|
|
363
|
+
],
|
|
364
|
+
}
|
|
365
|
+
if include_elements:
|
|
366
|
+
state["elements"] = [
|
|
367
|
+
{"id": e.id, "role": e.role, "name": e.name,
|
|
368
|
+
"bounds": e.bounds(), "center": list(e.center),
|
|
369
|
+
"enabled": e.enabled, "automation_id": e.automation_id or None}
|
|
370
|
+
for e in snap.elements
|
|
371
|
+
]
|
|
372
|
+
return state
|
|
373
|
+
|
|
374
|
+
def wait_for_element(self, query: str, *, timeout_s: float = MAX_WAIT_ELEMENT_S,
|
|
375
|
+
window: str | None = None) -> Element:
|
|
376
|
+
"""State-based wait (no blind sleep): poll until the element appears."""
|
|
377
|
+
deadline = time.monotonic() + clamp_wait(timeout_s, 30.0)
|
|
378
|
+
last_error: Exception | None = None
|
|
379
|
+
while time.monotonic() < deadline:
|
|
380
|
+
try:
|
|
381
|
+
return self.find_element(query, window=window, fresh=True)
|
|
382
|
+
except ElementNotFoundError as exc:
|
|
383
|
+
last_error = exc
|
|
384
|
+
time.sleep(MAX_WAIT_POLL_S)
|
|
385
|
+
raise last_error or ElementNotFoundError(f'"{query}" never appeared.')
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _app_from_title(title: str) -> str:
|
|
389
|
+
"""Best-effort app name from a window title ('Document — App' convention)."""
|
|
390
|
+
for sep in (" — ", " - ", " – "):
|
|
391
|
+
if sep in title:
|
|
392
|
+
return title.rsplit(sep, 1)[-1].strip() or title.strip()
|
|
393
|
+
return title.strip()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# --- shared session engine ------------------------------------------------------------
|
|
397
|
+
_ENGINE: ScreenEngine | None = None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def get_screen_engine(vision: Any = None, windows: Any = None) -> ScreenEngine:
|
|
401
|
+
"""Process-wide engine (built on first use; injectable for tests)."""
|
|
402
|
+
global _ENGINE
|
|
403
|
+
if _ENGINE is None or vision is not None or windows is not None:
|
|
404
|
+
if _ENGINE is None:
|
|
405
|
+
_ENGINE = ScreenEngine(vision=vision, windows=windows)
|
|
406
|
+
return _ENGINE
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def reset_screen_engine() -> None:
|
|
410
|
+
"""Drop the cached engine (tests, session teardown)."""
|
|
411
|
+
global _ENGINE
|
|
412
|
+
_ENGINE = None
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Self-guard: SeedCode must never act on its own terminal window or process.
|
|
2
|
+
|
|
3
|
+
This module exists to close the loop-hole behind the auto-exit bug: window
|
|
4
|
+
and process operations that match by *title substring* (``close_window``,
|
|
5
|
+
``taskkill /FI "WINDOWTITLE eq ..."``, focus-then-hotkey workflows) could
|
|
6
|
+
match SeedCode's own console window and terminate the host terminal — which
|
|
7
|
+
kills SeedCode with it. Sending a task like "close the browser" must never be
|
|
8
|
+
able to escalate into "close the terminal running SeedCode".
|
|
9
|
+
|
|
10
|
+
Every driver that can close, kill, or type into a window consults this
|
|
11
|
+
module first. Identification is positive — a window is only treated as ours
|
|
12
|
+
when we can *prove* it is (window handle equality, owning process in our
|
|
13
|
+
process tree, or an exact title match with the hosting console) — so the
|
|
14
|
+
guard can never block legitimate automation of unrelated windows:
|
|
15
|
+
|
|
16
|
+
* fail-open for unknown windows (they are simply not ours), and
|
|
17
|
+
* fail-closed for actions that would touch a proven-own target.
|
|
18
|
+
|
|
19
|
+
Pure stdlib (ctypes) on Windows; a safe no-op everywhere else, so importing
|
|
20
|
+
this module is free on any platform and in tests.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import ctypes
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
_windows_ok = sys.platform == "win32"
|
|
31
|
+
if _windows_ok:
|
|
32
|
+
import ctypes.wintypes # noqa: F401 — DWORD for GetWindowThreadProcessId
|
|
33
|
+
|
|
34
|
+
# Cache the process tree for the life of the process: our ancestry cannot
|
|
35
|
+
# change while we run, and walking it is not free.
|
|
36
|
+
_ancestors: "set[int] | None" = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --- process identification ---------------------------------------------------
|
|
40
|
+
def own_pid() -> int:
|
|
41
|
+
"""The current SeedCode process id."""
|
|
42
|
+
return os.getpid()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ancestor_pids(max_depth: int = 10) -> set[int]:
|
|
46
|
+
"""SeedCode's process id plus every ancestor's (shell, terminal host...).
|
|
47
|
+
|
|
48
|
+
Walking upward matters on modern Windows: Windows Terminal hosts the
|
|
49
|
+
shell in its own process, so the console window SeedCode runs inside may
|
|
50
|
+
be owned by an *ancestor* (wt.exe / WindowsTerminal.exe), not by us.
|
|
51
|
+
Killing that window would kill our terminal — exactly the auto-exit bug.
|
|
52
|
+
|
|
53
|
+
Best-effort: psutil is optional; without it only our own pid is returned.
|
|
54
|
+
"""
|
|
55
|
+
global _ancestors
|
|
56
|
+
if _ancestors is not None:
|
|
57
|
+
return _ancestors
|
|
58
|
+
pids = {own_pid()}
|
|
59
|
+
if not _windows_ok:
|
|
60
|
+
_ancestors = pids
|
|
61
|
+
return _ancestors
|
|
62
|
+
try:
|
|
63
|
+
import psutil # type: ignore
|
|
64
|
+
|
|
65
|
+
proc = psutil.Process(own_pid())
|
|
66
|
+
for _ in range(max_depth):
|
|
67
|
+
parent = proc.parent()
|
|
68
|
+
if parent is None or parent.pid in (0, pids):
|
|
69
|
+
break
|
|
70
|
+
pids.add(parent.pid)
|
|
71
|
+
proc = parent
|
|
72
|
+
except Exception:
|
|
73
|
+
# psutil missing or a process vanished — self alone is still correct.
|
|
74
|
+
pass
|
|
75
|
+
_ancestors = pids
|
|
76
|
+
return _ancestors
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_own_process(pid: Any) -> bool:
|
|
80
|
+
"""Whether ``pid`` belongs to SeedCode or the terminal hosting it."""
|
|
81
|
+
try:
|
|
82
|
+
return int(pid) in ancestor_pids()
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- console window identification ---------------------------------------------
|
|
88
|
+
def console_hwnd() -> int:
|
|
89
|
+
"""Window handle of the console hosting us (0 when detached/non-Windows)."""
|
|
90
|
+
if not _windows_ok:
|
|
91
|
+
return 0
|
|
92
|
+
try:
|
|
93
|
+
return int(ctypes.windll.kernel32.GetConsoleWindow())
|
|
94
|
+
except Exception:
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _window_pid(hwnd: int) -> int:
|
|
99
|
+
"""Process id owning ``hwnd`` (0 when unavailable)."""
|
|
100
|
+
if not _windows_ok or not hwnd:
|
|
101
|
+
return 0
|
|
102
|
+
try:
|
|
103
|
+
pid = ctypes.wintypes.DWORD()
|
|
104
|
+
ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
105
|
+
return int(pid.value)
|
|
106
|
+
except Exception:
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _window_title(hwnd: int) -> str:
|
|
111
|
+
"""Title text of ``hwnd`` (empty when unavailable)."""
|
|
112
|
+
if not _windows_ok or not hwnd:
|
|
113
|
+
return ""
|
|
114
|
+
try:
|
|
115
|
+
length = ctypes.windll.user32.GetWindowTextLengthW(hwnd)
|
|
116
|
+
buf = ctypes.create_unicode_buffer(length + 1)
|
|
117
|
+
ctypes.windll.user32.GetWindowTextW(hwnd, buf, length + 1)
|
|
118
|
+
return buf.value or ""
|
|
119
|
+
except Exception:
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def console_title() -> str:
|
|
124
|
+
"""The hosting console's current title ('' when detached/non-Windows)."""
|
|
125
|
+
hwnd = console_hwnd()
|
|
126
|
+
return _window_title(hwnd) if hwnd else ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def foreground_hwnd() -> int:
|
|
130
|
+
"""Window handle of the foreground window (0 when unavailable)."""
|
|
131
|
+
if not _windows_ok:
|
|
132
|
+
return 0
|
|
133
|
+
try:
|
|
134
|
+
return int(ctypes.windll.user32.GetForegroundWindow())
|
|
135
|
+
except Exception:
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def is_own_hwnd(hwnd: Any) -> bool:
|
|
140
|
+
"""Whether a window handle is SeedCode's own console (or its host).
|
|
141
|
+
|
|
142
|
+
Positive identification only: handle equality with the hosting console,
|
|
143
|
+
or a window whose owning process is in our process tree (catches Windows
|
|
144
|
+
Terminal, whose console API returns no classic console handle). Unknown
|
|
145
|
+
handles are *not* ours by definition — fail-open.
|
|
146
|
+
"""
|
|
147
|
+
if not _windows_ok or not hwnd:
|
|
148
|
+
return False
|
|
149
|
+
try:
|
|
150
|
+
hwnd_int = int(hwnd)
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
return False
|
|
153
|
+
if hwnd_int and hwnd_int == console_hwnd():
|
|
154
|
+
return True
|
|
155
|
+
return is_own_process(_window_pid(hwnd_int))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def foreground_is_own() -> bool:
|
|
159
|
+
"""Whether the currently focused window is SeedCode's own terminal.
|
|
160
|
+
|
|
161
|
+
Keystrokes synthesized while *our* window is focused were meant for some
|
|
162
|
+
other window whose focus attempt failed — delivering them here types into
|
|
163
|
+
the user's prompt or triggers our own key bindings. Callers refuse.
|
|
164
|
+
"""
|
|
165
|
+
hwnd = foreground_hwnd()
|
|
166
|
+
if not hwnd:
|
|
167
|
+
return False
|
|
168
|
+
return is_own_hwnd(hwnd)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --- pygetwindow-object helpers --------------------------------------------------
|
|
172
|
+
def is_own_window(win: Any) -> bool:
|
|
173
|
+
"""Whether a pygetwindow-style window object is SeedCode's own terminal.
|
|
174
|
+
|
|
175
|
+
Checks the object's ``_hWnd`` when present (the reliable path), falling
|
|
176
|
+
back to an *exact* title match with the hosting console. A substring is
|
|
177
|
+
deliberately NOT enough: "Command Prompt" also matches every other
|
|
178
|
+
console on the machine, and the user may legitimately want those closed.
|
|
179
|
+
"""
|
|
180
|
+
hwnd = getattr(win, "_hWnd", None)
|
|
181
|
+
if hwnd is not None and is_own_hwnd(hwnd):
|
|
182
|
+
return True
|
|
183
|
+
title = (getattr(win, "title", "") or "").strip()
|
|
184
|
+
own = console_title().strip()
|
|
185
|
+
return bool(title) and bool(own) and title.lower() == own.lower()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def is_own_title(title: str) -> bool:
|
|
189
|
+
"""Whether a window *title* is exactly our hosting console's title.
|
|
190
|
+
|
|
191
|
+
Used as a belt-and-braces check before ``taskkill`` window-title filters:
|
|
192
|
+
the filter matches by prefix, so a kill command whose filter equals our
|
|
193
|
+
console title would target our own terminal.
|
|
194
|
+
"""
|
|
195
|
+
own = console_title().strip()
|
|
196
|
+
candidate = (title or "").strip()
|
|
197
|
+
return bool(candidate) and bool(own) and candidate.lower() == own.lower()
|