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.
Files changed (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. 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()