@widgex/wgx 0.0.0-stage → 0.4.2

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 (131) hide show
  1. package/LICENSE.txt +49 -0
  2. package/README.md +33 -2
  3. package/ThirdPartyNotices.txt +4685 -0
  4. package/package.json +30 -4
  5. package/provider-clis.json +1211 -0
  6. package/python/XACTOR.md +146 -0
  7. package/python/browser_actor_host.py +456 -0
  8. package/python/cdp_page.py +363 -0
  9. package/python/harness_check.py +77 -0
  10. package/python/requirements.txt +1 -0
  11. package/python/test_line_endings.py +185 -0
  12. package/python/xactor/__init__.py +18 -0
  13. package/python/xactor/__main__.py +226 -0
  14. package/python/xactor/api.py +882 -0
  15. package/python/xactor/bridge.py +784 -0
  16. package/python/xactor/dom.py +117 -0
  17. package/python/xactor/man/add_card.md +89 -0
  18. package/python/xactor/man/agent-vs-fork.md +108 -0
  19. package/python/xactor/man/agent.md +333 -0
  20. package/python/xactor/man/ask_model.md +110 -0
  21. package/python/xactor/man/ask_reference.md +56 -0
  22. package/python/xactor/man/ask_user.md +103 -0
  23. package/python/xactor/man/attach_app.md +595 -0
  24. package/python/xactor/man/attach_ide.md +65 -0
  25. package/python/xactor/man/attach_ide_surface.md +31 -0
  26. package/python/xactor/man/await_user.md +214 -0
  27. package/python/xactor/man/bash.md +142 -0
  28. package/python/xactor/man/browser-driver.md +137 -0
  29. package/python/xactor/man/browser_activity.md +53 -0
  30. package/python/xactor/man/browser_attach.md +84 -0
  31. package/python/xactor/man/browser_close_tab.md +50 -0
  32. package/python/xactor/man/browser_detach.md +50 -0
  33. package/python/xactor/man/browser_exec.md +66 -0
  34. package/python/xactor/man/browser_focus_tab.md +47 -0
  35. package/python/xactor/man/browser_hybrid.md +47 -0
  36. package/python/xactor/man/browser_open.md +50 -0
  37. package/python/xactor/man/browser_open_tab.md +58 -0
  38. package/python/xactor/man/browser_plumbing.md +107 -0
  39. package/python/xactor/man/browser_screenshot.md +56 -0
  40. package/python/xactor/man/browser_snapshot.md +52 -0
  41. package/python/xactor/man/browser_tabs.md +67 -0
  42. package/python/xactor/man/bulletin.md +88 -0
  43. package/python/xactor/man/capture_page.md +54 -0
  44. package/python/xactor/man/cd.md +64 -0
  45. package/python/xactor/man/chess_move.md +53 -0
  46. package/python/xactor/man/children.md +89 -0
  47. package/python/xactor/man/close_task.md +14 -0
  48. package/python/xactor/man/compact.md +75 -0
  49. package/python/xactor/man/drive.md +140 -0
  50. package/python/xactor/man/drive_space.md +72 -0
  51. package/python/xactor/man/driving.md +168 -0
  52. package/python/xactor/man/edit_file.md +112 -0
  53. package/python/xactor/man/finding.md +194 -0
  54. package/python/xactor/man/fork_agent.md +45 -0
  55. package/python/xactor/man/glob.md +83 -0
  56. package/python/xactor/man/grep.md +115 -0
  57. package/python/xactor/man/harness.md +165 -0
  58. package/python/xactor/man/harness_check.md +70 -0
  59. package/python/xactor/man/harness_format.md +257 -0
  60. package/python/xactor/man/highlight.md +66 -0
  61. package/python/xactor/man/instruct.md +111 -0
  62. package/python/xactor/man/ls.md +63 -0
  63. package/python/xactor/man/man.md +86 -0
  64. package/python/xactor/man/merge_wiz.md +40 -0
  65. package/python/xactor/man/notebook.md +98 -0
  66. package/python/xactor/man/overview.md +89 -0
  67. package/python/xactor/man/permissions.md +137 -0
  68. package/python/xactor/man/phase.md +150 -0
  69. package/python/xactor/man/powershell.md +74 -0
  70. package/python/xactor/man/profiles.md +60 -0
  71. package/python/xactor/man/project_drive.md +49 -0
  72. package/python/xactor/man/read_bulletin.md +89 -0
  73. package/python/xactor/man/read_file.md +90 -0
  74. package/python/xactor/man/relay.md +216 -0
  75. package/python/xactor/man/reply.md +72 -0
  76. package/python/xactor/man/reply_message.md +78 -0
  77. package/python/xactor/man/report_bug.md +118 -0
  78. package/python/xactor/man/request_answer.md +142 -0
  79. package/python/xactor/man/request_merge_decisions.md +121 -0
  80. package/python/xactor/man/request_operator_action.md +152 -0
  81. package/python/xactor/man/request_review.md +122 -0
  82. package/python/xactor/man/request_user_action.md +126 -0
  83. package/python/xactor/man/return_card.md +71 -0
  84. package/python/xactor/man/run.md +99 -0
  85. package/python/xactor/man/sandbox.md +144 -0
  86. package/python/xactor/man/session_fork.md +74 -0
  87. package/python/xactor/man/session_harness.md +109 -0
  88. package/python/xactor/man/session_join.md +102 -0
  89. package/python/xactor/man/session_land.md +73 -0
  90. package/python/xactor/man/session_nudge.md +73 -0
  91. package/python/xactor/man/session_rest.md +73 -0
  92. package/python/xactor/man/session_scope.md +84 -0
  93. package/python/xactor/man/session_terminate.md +58 -0
  94. package/python/xactor/man/session_token.md +78 -0
  95. package/python/xactor/man/sessions.md +93 -0
  96. package/python/xactor/man/set_harness.md +15 -0
  97. package/python/xactor/man/spawn_swarm.md +80 -0
  98. package/python/xactor/man/submit_evidence.md +54 -0
  99. package/python/xactor/man/swarm_brief.md +30 -0
  100. package/python/xactor/man/task_status.md +82 -0
  101. package/python/xactor/man/tasks.md +47 -0
  102. package/python/xactor/man/todo_write.md +104 -0
  103. package/python/xactor/man/update_card.md +70 -0
  104. package/python/xactor/man/use_profile.md +90 -0
  105. package/python/xactor/man/use_widget.md +82 -0
  106. package/python/xactor/man/wait_for_task.md +105 -0
  107. package/python/xactor/man/widget-model.md +284 -0
  108. package/python/xactor/man/widgets.md +59 -0
  109. package/python/xactor/man/write_file.md +76 -0
  110. package/python/xactor/man/writing-a-widget.md +142 -0
  111. package/python/xactor/man/xylon.md +120 -0
  112. package/python/xactor/man/xylon_blame.md +80 -0
  113. package/python/xactor/man/xylon_read.md +131 -0
  114. package/python/xactor/man/xylon_search.md +125 -0
  115. package/python/xactor/manual.py +195 -0
  116. package/python/xactor/parent_watch.py +115 -0
  117. package/python/xactor/plan_mode.py +350 -0
  118. package/python/xactor/sandbox.py +856 -0
  119. package/python/xactor/seals.py +438 -0
  120. package/python/xactor/shell_hints.py +210 -0
  121. package/python/xactor/states/__init__.py +13 -0
  122. package/python/xactor/states/base.py +523 -0
  123. package/python/xactor/states/registry.py +274 -0
  124. package/python/xactor/stdlib_api.py +6345 -0
  125. package/python/xactor/widgets/__init__.py +1 -0
  126. package/python/xactor/widgets/github_tokens.py +314 -0
  127. package/python/xactor/widgets/google_aistudio.py +365 -0
  128. package/settings-schema.json +403 -0
  129. package/viewer/index.css +160 -0
  130. package/viewer/index.js +15 -0
  131. package/wgx.mjs +290659 -0
@@ -0,0 +1,882 @@
1
+ """ElementaryAPI -- Layer 1.
2
+
3
+ The elementary set of interactions an agent composes to drive a website. It is
4
+ deliberately small: everything here either *locates* something or *acts* on it,
5
+ and the two are separate verbs because the agent, not Layer 0, decides which
6
+ candidate was meant.
7
+
8
+ ## The three layers, and what each is for
9
+
10
+ **Layer 0** (TypeScript) receives serial actions whose intent is already
11
+ resolved. It searches when a descriptor asks it to, executes with humanised
12
+ input, and reports. It never chooses between close candidates. *An agent should
13
+ never need to understand Layer 0 to drive a website.*
14
+
15
+ **Layer 1** is this file. It is what the agent composes to turn a general
16
+ intuition about identifiable features -- "there is a button that says something
17
+ like Create, inside the dialog" -- into a concrete reference. The vocabulary
18
+ here must match how Layer 0 resolves, or the agent would be reading one model
19
+ and acting through another.
20
+
21
+ **Layer 2** (widgets) caches those compositions into declared transitions, so a
22
+ modelled site can be driven to an objective in a single pass.
23
+
24
+ ## Load a chain; stay out of the loop
25
+
26
+ Actions queue. They do not need to be awaited individually, and awaiting each
27
+ one is the habit this design exists to break::
28
+
29
+ api.click(api.find(role="button", like="create token"))
30
+ api.type_text(api.find(role="textbox", right_of="Name"), "ci-deploy")
31
+ api.click(api.find(role="button", text_exact="Create", under='dialog"Create Token"'))
32
+ report = await api.drain()
33
+
34
+ One report comes back describing every step: what it resolved to, whether it
35
+ worked, and what changed on the page. If step 3 cannot find its target, steps
36
+ after it are *skipped rather than attempted* -- the page is no longer in the
37
+ state the rest was written against -- and the report says so, with the
38
+ candidates that tied.
39
+
40
+ ## Time is bounded
41
+
42
+ Two ceilings, so a wedged page cannot swallow a run:
43
+
44
+ - **10s per action**, covering resolving, waiting for the target, and doing the
45
+ thing. An action past it reports ``timeout`` and *stops touching the page*.
46
+ - **30s for the whole chain**. Past that, actions not yet attempted report
47
+ ``budget_exhausted`` rather than running.
48
+
49
+ Neither is a target. A click is around 300ms and a twelve-step chain a few
50
+ seconds; these only fire when something is genuinely stuck.
51
+
52
+ **Typing is exempt from both, and you do not size it.** A ``type_text`` sets its
53
+ own ceiling from the string you gave it and the speed it will be typed at --
54
+ about 70ms a character, divided by ``hfactor``, doubled -- and adds that to the
55
+ chain's budget, so a form of long fields cannot exhaust it. A paragraph at
56
+ ``hfactor`` 1 simply takes the half-minute it takes -- and a quarter of that at
57
+ ``hfactor`` 4, because higher is faster.
58
+
59
+ So a ``timeout`` on a type is never "the string was long": the allowance already
60
+ knew the length. It means the field is not taking the keystrokes. Do not raise
61
+ ``timeout_ms`` at it -- read the message, then ``api.wait_stable()``.
62
+
63
+ type_text(field, essay) # no timeout_ms; it sizes itself
64
+ click(btn, batch_ms=60_000) # a deliberately long chain
65
+
66
+ ## Newlines are the Enter key
67
+
68
+ Put ``\n`` in the text and it is typed as a real Enter press, not as a
69
+ character: keyCode 13, ``code`` ``Enter``, in the page's own input pipeline.
70
+ ``\r\n`` is one press, not two.
71
+
72
+ Which means it does what Enter does *in that field*. In a textarea or a rich
73
+ editor it breaks the line; in a single-line input, or a chat composer bound to
74
+ send, it submits. If the field sends on Enter and you want a line break, type
75
+ the lines separately and press the shifted form between them::
76
+
77
+ type_text(box, "first line")
78
+ press("Shift+Enter", box)
79
+ type_text(box, "second line")
80
+
81
+ ## Referring to things
82
+
83
+ An element is named by a **uid** (short, content-derived, e.g. ``e7f3a1``) or by
84
+ a **path** through the page's structure::
85
+
86
+ main/table/row"prod-key"/button"Delete"
87
+ dialog"Create Token"/button"Create"
88
+ form#login/textbox"Username"
89
+
90
+ Both resolve. A path is an assertion about the page as it is now; a uid is a
91
+ memory of something observed. Neither is stamped into the page, so a stale
92
+ reference matches nothing or matches several -- it can never quietly resolve to
93
+ the wrong element.
94
+
95
+ The ``xh_N`` ids from the previous engine are gone and raise if used.
96
+ """
97
+
98
+ import asyncio
99
+ import sys
100
+ from typing import Any
101
+
102
+ from . import bridge
103
+
104
+
105
+ def _dbg(msg: str) -> None:
106
+ """Write debug output to stderr, where it cannot corrupt the JSON-RPC wire."""
107
+ print(f"[xactor:api] {msg}", file=sys.stderr, flush=True)
108
+
109
+
110
+ class Ref:
111
+ """A reference to an element, or a description of how to find one.
112
+
113
+ Returned by :meth:`find` and :meth:`one`, and accepted anywhere an action
114
+ wants a target. It also carries what Layer 0 observed -- the text, the box,
115
+ the declared events -- so the agent can decide without a second call.
116
+ """
117
+
118
+ __slots__ = ("uid", "path", "role", "text", "box", "center", "events",
119
+ "value", "state", "score", "in_view")
120
+
121
+ def __init__(self, data: dict[str, Any]) -> None:
122
+ self.uid = data.get("uid")
123
+ self.path = data.get("path")
124
+ self.role = data.get("role", "")
125
+ self.text = data.get("text", "")
126
+ self.box = data.get("box")
127
+ self.center = data.get("center")
128
+ self.events = data.get("events", [])
129
+ self.value = data.get("value")
130
+ self.state = data.get("state", {})
131
+ self.score = data.get("score")
132
+ self.in_view = data.get("inView", True)
133
+
134
+ @property
135
+ def disabled(self) -> bool:
136
+ return bool(self.state.get("disabled"))
137
+
138
+ def as_target(self) -> str:
139
+ """How this crosses to Layer 0.
140
+
141
+ The uid *and* the path when both are known: the uid is what identifies
142
+ the element, and the path lets Layer 0 notice it has moved somewhere
143
+ that changes what a click would mean.
144
+ """
145
+ if self.uid and self.path:
146
+ return f"{self.uid}@{self.path}"
147
+ return self.uid or self.path or ""
148
+
149
+ def __repr__(self) -> str:
150
+ bits = [self.role or "?"]
151
+ if self.text:
152
+ bits.append(repr(self.text))
153
+ if self.score is not None:
154
+ bits.append(f"score={self.score:.2f}")
155
+ return f"<Ref {' '.join(bits)} {self.path or self.uid}>"
156
+
157
+
158
+ def _target(t: Any) -> Any:
159
+ """Normalise whatever an action was given into something Layer 0 accepts."""
160
+ if isinstance(t, Ref):
161
+ return t.as_target()
162
+ if isinstance(t, FindSpec):
163
+ return t.spec
164
+ if isinstance(t, dict):
165
+ return t
166
+ return str(t)
167
+
168
+
169
+ class FindSpec:
170
+ """A search deferred until the queue reaches the action holding it.
171
+
172
+ This is what makes ``click(find(...))`` work on something that does not
173
+ exist yet -- the dialog button an earlier action in the same chain is about
174
+ to bring into being. Nothing is evaluated when you build it.
175
+ """
176
+
177
+ __slots__ = ("spec",)
178
+
179
+ def __init__(self, spec: dict[str, Any]) -> None:
180
+ self.spec = spec
181
+
182
+ def __repr__(self) -> str:
183
+ return f"<FindSpec {self.spec}>"
184
+
185
+
186
+ def _spec(
187
+ role: str | list[str] | None = None,
188
+ like: str | None = None,
189
+ text_exact: str | None = None,
190
+ under: Any = None,
191
+ near: Any = None,
192
+ above: Any = None,
193
+ below: Any = None,
194
+ left_of: Any = None,
195
+ right_of: Any = None,
196
+ in_row: Any = None,
197
+ in_section: Any = None,
198
+ attr: dict[str, str] | None = None,
199
+ state: dict[str, bool] | None = None,
200
+ in_view: bool | None = None,
201
+ interactive: bool | None = None,
202
+ include_inert: bool | None = None,
203
+ limit: int | None = None,
204
+ margin: float | None = None,
205
+ ) -> dict[str, Any]:
206
+ """Build the wire form of a search. Snake_case in, camelCase out."""
207
+ out: dict[str, Any] = {}
208
+ if role is not None:
209
+ out["role"] = role
210
+ if like is not None:
211
+ out["like"] = like
212
+ if text_exact is not None:
213
+ out["textExact"] = text_exact
214
+ for key, val in (
215
+ ("under", under), ("near", near), ("above", above), ("below", below),
216
+ ("leftOf", left_of), ("rightOf", right_of),
217
+ ("inRow", in_row), ("inSection", in_section),
218
+ ):
219
+ if val is not None:
220
+ out[key] = _target(val)
221
+ if attr is not None:
222
+ out["attr"] = attr
223
+ if state is not None:
224
+ out["state"] = state
225
+ if in_view is not None:
226
+ out["inView"] = in_view
227
+ if interactive is not None:
228
+ out["interactive"] = interactive
229
+ if include_inert is not None:
230
+ out["includeInert"] = include_inert
231
+ if limit is not None:
232
+ out["limit"] = limit
233
+ if margin is not None:
234
+ out["margin"] = margin
235
+ return out
236
+
237
+
238
+ class ElementaryAPI:
239
+ """Layer 1. Compose these to locate things; hand the result to an action."""
240
+
241
+ #: Actions scheduled but not yet collected by :meth:`drain`.
242
+ _pending: "list[asyncio.Future[Any]] | None" = None
243
+ #: The most recently scheduled action, so the next one can follow it.
244
+ _tail: "asyncio.Future[Any] | None" = None
245
+
246
+ def _enqueue(self, coro: "Any") -> "asyncio.Future[Any]":
247
+ """Schedule an action now and hand back something awaitable.
248
+
249
+ This is what makes ``api.click(x)`` work without ``await``.
250
+
251
+ A bare ``async def`` cannot: a coroutine nobody awaits is never
252
+ scheduled, so it simply does not happen — silently, apart from a
253
+ RuntimeWarning nobody sees in a webview. The docstrings below have
254
+ always promised that actions queue and ``drain()`` collects them, and
255
+ that promise needs a task rather than a coroutine behind it.
256
+
257
+ Chained rather than fired in parallel. Actions are ordered by
258
+ construction — you clear a field *then* click Save — and letting the
259
+ event loop interleave two of them would reorder the page's input for
260
+ no gain, because the queue that matters is the host's and it is fed in
261
+ receipt order.
262
+
263
+ Awaiting the returned future gives that action's own record, exactly as
264
+ before; ignoring it leaves the work scheduled and lets ``drain()``
265
+ report on it.
266
+ """
267
+ if self._pending is None:
268
+ self._pending = []
269
+ prev = self._tail
270
+
271
+ async def _run() -> Any:
272
+ if prev is not None:
273
+ # A failed predecessor must not cancel what follows here: Layer
274
+ # 0 decides what a failure does to the rest of the chain and
275
+ # reports it as `skipped`. This only enforces order.
276
+ try:
277
+ await prev
278
+ except Exception:
279
+ pass
280
+ return await coro
281
+
282
+ task = asyncio.ensure_future(_run())
283
+ self._pending.append(task)
284
+ self._tail = task
285
+ return task
286
+
287
+ # ── Locating ──────────────────────────────────────────────────────
288
+
289
+ def find(self, **kw: Any) -> FindSpec:
290
+ """Describe a search, without running it.
291
+
292
+ Use this **inside an action** to defer the lookup to dispatch time::
293
+
294
+ api.click(api.find(role="button", like="create token"))
295
+
296
+ That is the form to reach for by default: it costs no round trip when
297
+ you build it, and it resolves against the page as it will be when the
298
+ click actually happens rather than as it is now.
299
+
300
+ To *see* the candidates instead, await :meth:`search`.
301
+
302
+ Args:
303
+ role: An ARIA role, or a list of them. Same vocabulary the paths
304
+ use -- ``button``, ``link``, ``textbox``, ``row``, ``dialog``.
305
+ like: Approximate text. Ranked, not filtered.
306
+ text_exact: Exact text, after normalisation.
307
+ under: Restrict to a subtree, by ref or path.
308
+ near / above / below / left_of / right_of: Geometric relations. The
309
+ anchor may be a ref, a path, or just the visible text of a label
310
+ -- ``right_of="Name"`` is how you reach the field beside a
311
+ caption that is not itself addressable.
312
+ in_row / in_section: Containment, for tables and landmarks.
313
+ state: e.g. ``{"enabled": True}``.
314
+ in_view: Default True. Off-screen matches are excluded and counted.
315
+ margin: How decisive the ranking must be before Layer 0 will act.
316
+ ``0`` takes the top rank; ``1`` acts only if nothing else
317
+ matched at all. Default 0.15.
318
+ """
319
+ return FindSpec(_spec(**kw))
320
+
321
+ async def search(self, **kw: Any) -> list[Ref]:
322
+ """Run a search now and return the ranked candidates.
323
+
324
+ Zero results is an ordinary answer, not an error -- "I cannot find it"
325
+ is the normal case on a graphical UI, and it is information.
326
+
327
+ Each :class:`Ref` carries its ``score``. When the top two are close,
328
+ that is the page telling you the description was not specific enough::
329
+
330
+ cands = await api.search(role="button", like="create")
331
+ # [<Ref button 'Create Token' score=0.90>,
332
+ # <Ref button 'Create Team' score=0.90>]
333
+ """
334
+ result = await bridge.call("find", find=_spec(**kw))
335
+ if isinstance(result, dict) and result.get("error"):
336
+ _dbg(f"search error: {result}")
337
+ return []
338
+ nodes = (result or {}).get("nodes") or []
339
+ return [Ref(n) for n in nodes]
340
+
341
+ async def one(self, **kw: Any) -> Ref:
342
+ """Exactly one match, or raise with the candidates attached.
343
+
344
+ Use when you believe the description is unambiguous and want to be told
345
+ immediately if it is not. The exception body lists what tied, which is
346
+ the refinement hint for your next attempt.
347
+ """
348
+ result = await bridge.call("one", find=_spec(**kw))
349
+ if isinstance(result, dict) and result.get("error"):
350
+ cands = result.get("candidates") or []
351
+ detail = "\n".join(
352
+ f" {c.get('score', 0):.2f} {c.get('path') or c.get('uid')}"
353
+ f" {c.get('text', '')!r}"
354
+ for c in cands
355
+ )
356
+ raise LookupError(
357
+ f"{result['error']}: no single match for {_spec(**kw)}"
358
+ + (f"\ncandidates:\n{detail}" if detail else "")
359
+ )
360
+ return Ref(result)
361
+
362
+ async def outline(self, under: Any = None, depth: int = 2) -> dict[str, Any]:
363
+ """The shape of the page -- roles and names, no geometry.
364
+
365
+ The cheapest way to orient. Read this before reaching for a screenshot;
366
+ a screenshot puts you back in the loop for every lookup, which is the
367
+ cost this whole design exists to avoid.
368
+ """
369
+ return await bridge.call("outline", under=_target(under) if under else None, depth=depth)
370
+
371
+ async def snapshot(self, **kw: Any) -> dict[str, Any]:
372
+ """The interactive tree, filtered.
373
+
374
+ Conservative by default: in-viewport, interactive elements only, with
375
+ everything omitted reported as a count so you always know what you are
376
+ not being shown.
377
+ """
378
+ return await bridge.call("tag", **_spec(**kw))
379
+
380
+ async def digest(self) -> dict[str, Any]:
381
+ """A cheap rollup of the page, for "did anything change?"."""
382
+ return await bridge.call("digest")
383
+
384
+ # ── Acting ────────────────────────────────────────────────────────
385
+ #
386
+ # These queue. Awaiting one is allowed and gives you its record; not
387
+ # awaiting is the point, and `drain()` collects the lot.
388
+ #
389
+ # They return a scheduled task rather than being `async def`, which is what
390
+ # makes the second half of that true — see `_enqueue`. `await api.click(x)`
391
+ # behaves exactly as it always has.
392
+
393
+ def click(self, target: Any, **kw: Any) -> "asyncio.Future[Any]":
394
+ """Click something. Returns the record of what happened."""
395
+ return self._enqueue(bridge.call("click", selector=_target(target), **_action_kw(kw)))
396
+
397
+ def double_click(self, target: Any, **kw: Any) -> "asyncio.Future[Any]":
398
+ """Double-click something.
399
+
400
+ The same verb as :meth:`click` with a count, which is what the DOM says
401
+ it is: the two presses land back-to-back at one point, so the page sees
402
+ `dblclick` rather than two separate clicks.
403
+
404
+ Reach for it when single and double click on the same element mean
405
+ different things -- opening versus renaming, a chat versus a card. If
406
+ the interface offers a button for what the double click does, prefer
407
+ the button: it is what a keyboard user has to press anyway.
408
+ """
409
+ return self._enqueue(
410
+ bridge.call("click", selector=_target(target), click_count=2, **_action_kw(kw)))
411
+
412
+ def type_text(self, target: Any, text: str, **kw: Any) -> "asyncio.Future[Any]":
413
+ """Type into a field, appending to whatever is there.
414
+
415
+ ``\\n`` is typed as a real Enter press -- a line break in a textarea, a
416
+ send in anything bound to send. The action sizes its own timeout from
417
+ the length of ``text``, so do not pass ``timeout_ms``.
418
+ """
419
+ return self._enqueue(
420
+ bridge.call("type", selector=_target(target), text=text, **_action_kw(kw)))
421
+
422
+ def clear_and_type(self, target: Any, text: str, **kw: Any) -> "asyncio.Future[Any]":
423
+ """Replace a field's contents. Same newline and timeout rules as
424
+ :meth:`type_text`."""
425
+ return self._enqueue(
426
+ bridge.call("clear_type", selector=_target(target), text=text, **_action_kw(kw)))
427
+
428
+ def hover(self, target: Any, **kw: Any) -> "asyncio.Future[Any]":
429
+ return self._enqueue(bridge.call("hover", selector=_target(target), **_action_kw(kw)))
430
+
431
+ def scroll_to(self, target: Any, **kw: Any) -> "asyncio.Future[Any]":
432
+ return self._enqueue(bridge.call("scroll_to", selector=_target(target), **_action_kw(kw)))
433
+
434
+ def select(self, target: Any, value: str | None = None,
435
+ label: str | None = None, **kw: Any) -> "asyncio.Future[Any]":
436
+ """Choose an option in a native ``<select>``.
437
+
438
+ A custom ARIA combobox is a different thing and this refuses it rather
439
+ than guessing: click the trigger, then click the option scoped to the
440
+ listbox it opens.
441
+ """
442
+ return self._enqueue(bridge.call("select", selector=_target(target),
443
+ value=value, label=label, **_action_kw(kw)))
444
+
445
+ def focus(self, target: Any, **kw: Any) -> "asyncio.Future[Any]":
446
+ """Put keyboard focus on something.
447
+
448
+ The other half of keyboard navigation. :meth:`press` types at whatever
449
+ is focused; this is how you decide what that is.
450
+
451
+ Reach for it when a control cannot be clicked. A custom combobox --
452
+ react-select and everything shaped like it -- hides a 1-2px input under
453
+ a styled div, so a click is correctly refused as *covered*, and the
454
+ wrapper's class is a build hash not worth writing down. The keyboard is
455
+ the way in, and it is also how a person would do it::
456
+
457
+ api.focus('#Project--permission')
458
+ api.press('ArrowDown', times=2) # open, then move
459
+ api.press('Enter') # commit
460
+
461
+ Reports ``not_interactable`` rather than ``ok`` when the element
462
+ refuses focus -- a disabled control, something inert, a div with no
463
+ tabindex. Assuming it took would send the next ``press`` to whatever
464
+ was focused before.
465
+ """
466
+ return self._enqueue(bridge.call("focus", selector=_target(target), **_action_kw(kw)))
467
+
468
+ def press(self, keys: str, target: Any = None, times: int | None = None,
469
+ **kw: Any) -> "asyncio.Future[Any]":
470
+ """Press a key or chord, e.g. ``"Enter"`` or ``"Control+a"``.
471
+
472
+ Args:
473
+ keys: The chord. Modifiers join with ``+``: ``"Control+a"``.
474
+ target: Optional. Focus this first, so one step can mean "press
475
+ Enter *in this field*" rather than "press Enter at whatever the
476
+ page last focused".
477
+ times: Repeat the chord. For walking a listbox without writing the
478
+ same step four times. Capped at 50 -- a runaway repeat is a
479
+ held key the page cannot recover from.
480
+ """
481
+ extra: dict[str, Any] = {}
482
+ if target is not None:
483
+ extra["selector"] = _target(target)
484
+ if times is not None:
485
+ extra["times"] = times
486
+ return self._enqueue(bridge.call("press", keys=keys, **extra, **_action_kw(kw)))
487
+
488
+ async def navigate(self, url: str) -> dict[str, Any]:
489
+ # Not an `_api_method`, so it never passed the seal check `drive` does:
490
+ # `navigate('file:///…/secret')` and then `outline()` read a sealed
491
+ # file straight back. The browser refuses a sealed `file://` load on
492
+ # its own too (see `sealedFileUrl` in the TS browser layer); this is
493
+ # the earlier, clearer refusal.
494
+ from . import seals
495
+ meta = bridge.exec_meta.get() or {}
496
+ seals.check_call("navigate", {"url": url}, meta.get("modelId"))
497
+ return await bridge.call("navigate", url=url)
498
+
499
+ # ── Guiding a person through the app ────────────────────────────
500
+ #
501
+ # Ringing a control and putting a sentence beside it are ways of driving a
502
+ # surface, like clicking and typing, so they are verbs here rather than a
503
+ # feature somewhere above. The agent guides live: ring something, see
504
+ # whether it resolved, decide what to say next. Nothing is predicted in
505
+ # advance, so nothing has to be validated in advance.
506
+ #
507
+ # Awaited rather than queued. A chain of clicks is worth batching because
508
+ # nobody is watching it; these exist precisely because somebody is, and each
509
+ # one is a thing the agent should see the result of before it says the next
510
+ # sentence.
511
+
512
+ async def highlight(self, target: Any = None, label: str | None = None) -> dict[str, Any]:
513
+ """Ring a control in the app. Pass nothing to take the ring down.
514
+
515
+ Drawn inside the page, so it appears whether the app is in its own
516
+ window or being mirrored — a mirror is a picture of the page, and the
517
+ ring is part of the page.
518
+
519
+ Returns ``{status, box}`` when it drew. When it could not, the reason
520
+ comes back rather than raising: ``that description matches more than one
521
+ control`` is the agent's to narrow, and ``that control is not on this
522
+ screen`` is usually the app being somewhere else, which is worth saying
523
+ to the user rather than failing on.
524
+ """
525
+ if target is None:
526
+ return await bridge.call("highlight", selector=None)
527
+ return await bridge.call(
528
+ "highlight", selector=_target(target), **({"label": label} if label else {}),
529
+ )
530
+
531
+ async def instruct(
532
+ self,
533
+ text: str | None,
534
+ target: Any = None,
535
+ *,
536
+ expect: str | None = None,
537
+ title: str = "",
538
+ step: int | None = None,
539
+ total: int | None = None,
540
+ ) -> dict[str, Any]:
541
+ """Put a sentence in the app, beside a control when you name one.
542
+
543
+ With a ``target`` the card is placed against that control with a pointer
544
+ and the control is ringed; without one it takes a corner, which is right
545
+ for "watch what happens next" and wrong for anything naming a button.
546
+
547
+ Pass ``text=None`` to take it down.
548
+
549
+ Keep it short and imperative — it sits beside a live app and is skimmed,
550
+ not read. Name the control in the words as well as ringing it: the ring
551
+ is dropped whenever the geometry cannot be proven, and a sentence that
552
+ leans on it breaks exactly when the safety net fires.
553
+
554
+ ``step``/``total`` are optional and only draw a counter. Omit them for a
555
+ single instruction; pass them when you are walking somebody through
556
+ several and they will want to know how far along they are.
557
+ """
558
+ kw: dict[str, Any] = {"text": text, "title": title}
559
+ if target is not None:
560
+ kw["selector"] = _target(target)
561
+ if expect is not None:
562
+ kw["expect"] = expect
563
+ if step is not None:
564
+ kw["step"] = step
565
+ if total is not None:
566
+ kw["total"] = total
567
+ return await bridge.call("instruct", **kw)
568
+
569
+ async def reply(self, text: str) -> dict[str, Any]:
570
+ """Answer a question without taking the step away.
571
+
572
+ For what comes back as ``asked``. The instruction, the ring and the
573
+ counter all stay exactly as they were and the answer goes underneath —
574
+ a word with somebody about the step they are on, rather than a new step.
575
+
576
+ Do not use :meth:`instruct` for this. That call *is* a new step: it
577
+ moves the ring, resets the counter and replaces the words, so answering
578
+ with it takes away the very sentence the question was about.
579
+
580
+ Then call :meth:`await_user` again on the same target to hand the step
581
+ back to them.
582
+ """
583
+ return await bridge.call("reply", text=text)
584
+
585
+ async def await_user(
586
+ self, target: Any = None, timeout_seconds: int = 300
587
+ ) -> dict[str, Any]:
588
+ """Wait for the person to do something, and say what they did.
589
+
590
+ One wait, five answers, because a person being guided has more than one
591
+ thing they might do::
592
+
593
+ {"clicked": True} they clicked the control you rang
594
+ {"advanced": True} they pressed Next
595
+ {"reported": True} they marked what is wrong and sent it
596
+ {"asked": "why this one?"} they typed a question
597
+ {"stopped": True} they ended it — Finish, or the chat
598
+ {"clicked": False, "reason": …} nothing, for the whole window
599
+
600
+ ``reported`` always carries ``annotations`` and means they stopped to
601
+ say something is wrong rather than doing what the step asked. Deal with
602
+ what is marked before writing another step.
603
+
604
+ Any of them may also carry ``annotations``: boxes somebody drew on the
605
+ app with "Mark what is wrong", each with a note and a picture of the
606
+ screen as it was when they wrote it. **That is the point of showing
607
+ somebody something rather than asking them.** A demo walked end to end
608
+ can still be a demo of six broken screens, so deal with what they
609
+ marked before you claim anything works. Regions are normalised 0-1, so
610
+ they survive the window being resized.
611
+
612
+ ``stopped`` is not a failure and not something to argue with. Take the
613
+ card down with ``instruct(None)``, say what you were about to show in a
614
+ sentence, and stop.
615
+
616
+ The tour also raises a row in the chat and the Needs-you queue for as
617
+ long as it runs, so somebody looking at neither the app window nor this
618
+ conversation can still see that a demo is waiting on them. It closes
619
+ when you call ``instruct(None)``. Answering *there* settles this wait —
620
+ whichever surface they reach for wins.
621
+
622
+ A press never reaches this side in a real window — the app handles it —
623
+ so it is reported out of the page and tested the way a mirrored click
624
+ is: both ends inside the control, barely any travel between them, so a
625
+ press that slides off stays the cancelled click it looks like.
626
+
627
+ ``target`` is optional. Omit it for a step with nothing to click ("read
628
+ this, then carry on"); Next and the question box are still there.
629
+
630
+ The timeout is **not an error**. Somebody who did something else is not
631
+ a failure, and noticing it is how you say something useful next.
632
+
633
+ Answer a question with :meth:`reply`, then wait again on the same
634
+ target. :meth:`instruct` *is* a new step — it moves the ring, resets the
635
+ counter and replaces the words — so answering with it takes away the
636
+ sentence the question was about. Reach for it only when the answer
637
+ genuinely lives somewhere else; a question is allowed to take the tour
638
+ off course, and ``title="detour"`` is how you say so.
639
+ """
640
+ return await bridge.call(
641
+ "await_user",
642
+ selector=_target(target) if target is not None else None,
643
+ timeout_ms=timeout_seconds * 1000,
644
+ )
645
+
646
+ async def drain(self) -> dict[str, Any]:
647
+ """Wait for every queued action and return one report for the chain.
648
+
649
+ The report names each action, what it resolved to, whether it worked,
650
+ and what changed. A failure stops the chain -- the remainder is reported
651
+ as ``skipped`` rather than attempted.
652
+ """
653
+ # Settle anything scheduled but not awaited before asking the host for
654
+ # the report. Without this, `drain()` races the actions it is meant to
655
+ # be reporting on and returns `{'actions': [], 'ok': 0}` — which reads
656
+ # as "the chain ran and did nothing" rather than "it has not run yet".
657
+ pending, self._pending, self._tail = (self._pending or []), [], None
658
+ records: list[Any] = []
659
+ for task in pending:
660
+ try:
661
+ rec = await task
662
+ if isinstance(rec, dict):
663
+ records.append(rec)
664
+ except Exception as err: # noqa: BLE001 - reported as a failed action
665
+ _dbg(f"queued action failed: {err}")
666
+ records.append({"kind": "unknown", "ok": False,
667
+ "outcome": "error", "error": str(err)})
668
+
669
+ host = await bridge.call("drain")
670
+ host = host if isinstance(host, dict) else {}
671
+
672
+ # The host's queue is empty by now, because every action's own
673
+ # `bridge.call` already resolved with its record — so its report counts
674
+ # nothing. The chain-level report the docstring promises has to be
675
+ # assembled from the records we collected, and the host's is kept only
676
+ # for the fields it alone knows (the url before/after, the elapsed ms).
677
+ if records and not (host.get("actions") or []):
678
+ host = {
679
+ **host,
680
+ "actions": records,
681
+ "ok": sum(1 for r in records if r.get("ok")),
682
+ "failed": sum(1 for r in records if not r.get("ok")),
683
+ "skipped": host.get("skipped", 0),
684
+ }
685
+ return host
686
+
687
+ # ── Waiting ───────────────────────────────────────────────────────
688
+
689
+ async def wait_for(self, target: Any, condition: str = "visible",
690
+ timeout_ms: int | None = None) -> dict[str, Any]:
691
+ """Sleep until an element is there.
692
+
693
+ Rarely needed explicitly: an action already waits for its own target.
694
+ Reach for it when you want to wait for something you are not about to
695
+ touch -- a spinner to go (``condition="gone"``), or a result to appear.
696
+ """
697
+ return await bridge.call("wait_for_element", selector=_target(target),
698
+ condition=condition, timeout=timeout_ms)
699
+
700
+ async def wait_stable(self, timeout_ms: int | None = None) -> dict[str, Any]:
701
+ """Wait for the page to stop loading and stop changing."""
702
+ return await bridge.call("wait_stable", timeout=timeout_ms)
703
+
704
+ # ── DOM-level input, for pages that never paint ───────────────────
705
+ #
706
+ # `click` and `type_text` synthesise real input events, which is what makes
707
+ # them humanised -- and what makes them useless on a background page.
708
+ # Chromium does not deliver synthesised input to a document whose
709
+ # visibilityState is "hidden", and it does not complain either. Layer 0 now
710
+ # detects that and fails with `page_hidden` instead of reporting success,
711
+ # but these two are the way to actually do the work there.
712
+
713
+ async def dom_click(self, target: Any) -> dict[str, Any]:
714
+ """Click through the DOM, without synthesising input."""
715
+ return await bridge.call("dom_click", selector=_target(target))
716
+
717
+ async def dom_fill(self, target: Any, text: str) -> dict[str, Any]:
718
+ """Set a field's value so React and Angular notice."""
719
+ return await bridge.call("dom_fill", selector=_target(target), text=text)
720
+
721
+ async def upload(self, target: Any, paths: Any) -> dict[str, Any]:
722
+ """Attach file(s) from disk to an ``<input type=file>``.
723
+
724
+ Page script cannot do this, so it is a verb of its own. ``target`` may be
725
+ the input or the styled button / drop zone around it; ``paths`` is an
726
+ absolute path or a list of them. The page gets its ``change`` event as
727
+ if the user had picked the files in the dialog. Reports
728
+ ``{"error": "no_file_input"}`` when nothing file-shaped is there.
729
+ """
730
+ if isinstance(paths, (str, bytes)) or not hasattr(paths, "__iter__"):
731
+ paths = [paths]
732
+ return await bridge.call("upload", selector=_target(target), paths=[str(p) for p in paths])
733
+
734
+ # ── Reading ───────────────────────────────────────────────────────
735
+
736
+ async def evaluate(self, expr: str) -> Any:
737
+ """Evaluate JavaScript in the page. The escape hatch."""
738
+ return await bridge.call("evaluate", expr=expr)
739
+
740
+ async def get_state(self) -> dict[str, Any]:
741
+ """The page's URL and title. **Not** the widget's state machine.
742
+
743
+ For where you are in a loaded widget, use :meth:`here`.
744
+ """
745
+ return await bridge.call("get_state")
746
+
747
+ def _surface(self) -> Any:
748
+ """The `api` a widget should see — the whole one, not just this half.
749
+
750
+ Layer 1 is grafted onto the stdlib `api` as *bound* methods, so `self`
751
+ inside them is this ElementaryAPI rather than the object the agent
752
+ calls. That is harmless for a verb that only reaches `bridge`, and
753
+ wrong the moment one hands `self` onward: a `PageState` built from it
754
+ can dispatch `click` and not `request_operator_action`, so a widget
755
+ step that asks the user to sign in dies with "ElementaryAPI has no
756
+ method 'request_operator_action'" — a page verb and a shell verb
757
+ sitting on the same `api` and only one of them reaching a widget.
758
+
759
+ `_merged` is set by `_graft_browser_l1`. Absent in the browser-only
760
+ shell, where this object really is the whole surface.
761
+ """
762
+ return getattr(self, "_merged", self)
763
+
764
+ async def here(self) -> Any:
765
+ """Which of the loaded widget's states the page is on right now.
766
+
767
+ The same thing as ``await registry.here(api)``, reachable by looking at
768
+ ``api``.
769
+
770
+ That is the entire reason it exists. ``registry`` is a bare name in the
771
+ shell scope, so it does not appear in ``dir(api)`` — and ``dir(api)`` is
772
+ what a model actually does when it wants to know what it can call. A
773
+ session that had already loaded a widget correctly went on to invent
774
+ ``api.widget_action``, then tried ``api.get_state()['transitions']``,
775
+ then ``api.drive('create_token')``, and never once reached the machine
776
+ that was sitting right there.
777
+
778
+ Returns a ``PageState``. ``describe()`` lists its transitions;
779
+ ``execute(name, **params)`` runs one — or use :meth:`transition`.
780
+ """
781
+ from .states import StateRegistry
782
+ return await StateRegistry.here(self._surface())
783
+
784
+ async def transition(self, name: str, /, **params: Any) -> Any:
785
+ """Run one of the current state's named transitions.
786
+
787
+ Shorthand for ``(await api.here()).execute(name, **params)``, which is
788
+ the whole of a widget-driven task once ``use_widget`` has landed you
789
+ somewhere.
790
+
791
+ Returns the ``PageState`` you end up in, so a flow reads as a chain::
792
+
793
+ await api.use_widget("sentry_tokens")
794
+ await api.transition("create_token")
795
+ await api.transition("configure", token_name="ci-deploy")
796
+ """
797
+ state = await self.here()
798
+ return await state.execute(name, **params)
799
+
800
+ async def screenshot(self, max_width: int | None = None) -> dict[str, Any]:
801
+ """Capture the page as an image.
802
+
803
+ An image is *seen, not queryable*, and looking at one costs a round trip
804
+ through you. Prefer :meth:`outline`, :meth:`search` and the spatial
805
+ predicates -- ``right_of="Name"`` locates a field without a picture.
806
+ Use this for genuinely visual questions: layout, rendering, or
807
+ confirming a human did something you asked them to.
808
+ """
809
+ params: dict[str, Any] = {}
810
+ if max_width is not None:
811
+ params["max_width"] = max_width
812
+ return await bridge.call("screenshot", **params)
813
+
814
+ async def extract_secret(self, target: Any, env_var: str) -> dict[str, Any]:
815
+ """Write a revealed value to `NAME=value` in the workspace's `.env`,
816
+ without it passing through this layer.
817
+
818
+ It lands in the file, not in any process's environment: `api.bash`
819
+ sees `$env_var` only after loading it (`set -a; . ./.env; set +a`).
820
+ """
821
+ return await bridge.call("extract_secret", selector=_target(target), env_var=env_var)
822
+
823
+ async def set_hfactor(self, value: float) -> dict[str, Any]:
824
+ """Humanisation speed. **Higher is faster.** 1.0 is normal.
825
+
826
+ It divides every delay rather than being one, which is the bit that
827
+ gets read backwards: 2.0 halves the pauses and runs twice as fast;
828
+ 0.5 doubles them and runs half as fast. To speed a script up, go up.
829
+
830
+ Per-dispatcher, so raising it for an unattended login does not leave a
831
+ global switch flipped for everything else.
832
+ """
833
+ return await bridge.call("set_hfactor", value=value)
834
+
835
+ # ── Withdrawn ─────────────────────────────────────────────────────
836
+
837
+ async def query(self, *_args: Any, **_kwargs: Any) -> Any:
838
+ """Withdrawn. Use :meth:`search` or :meth:`find`.
839
+
840
+ Kept as a refusal rather than deleted: an agent calling a method that no
841
+ longer exists gets an AttributeError and guesses, and its guess is
842
+ usually another name that is also missing.
843
+ """
844
+ raise RuntimeError(
845
+ "api.query() is withdrawn -- it returned a flat list with xh_ ids that "
846
+ "dangled on re-render.\n"
847
+ " await api.search(role=..., like=...) ranked candidates you can look at\n"
848
+ " api.find(role=..., like=...) a search deferred into an action\n"
849
+ " await api.one(role=..., text_exact=...) exactly one, or the candidates"
850
+ )
851
+
852
+ async def hybrid_snapshot(self, *_args: Any, **_kwargs: Any) -> Any:
853
+ """Withdrawn. Use :meth:`snapshot` or :meth:`outline`."""
854
+ raise RuntimeError(
855
+ "api.hybrid_snapshot() is withdrawn -- it returned a flat list with no "
856
+ "containment, so eight identical 'Delete' buttons were indistinguishable.\n"
857
+ " await api.outline() the shape of the page, cheapest\n"
858
+ " await api.snapshot() the interactive tree, filtered"
859
+ )
860
+
861
+ async def query_one(self, *_args: Any, **_kwargs: Any) -> Any:
862
+ """Withdrawn. Use :meth:`one`."""
863
+ raise RuntimeError("api.query_one() is withdrawn -- use await api.one(...)")
864
+
865
+
866
+ def _action_kw(kw: dict[str, Any]) -> dict[str, Any]:
867
+ """Pass through the per-action knobs, dropping anything unset.
868
+
869
+ ``timeout_ms`` is the action's whole ceiling -- resolving, waiting for its
870
+ target, and performing it -- not just one phase of it. ``batch_ms`` sets the
871
+ total for the chain and is read from whichever action opens it.
872
+ """
873
+ out: dict[str, Any] = {}
874
+ if kw.get("margin") is not None:
875
+ out["margin"] = kw["margin"]
876
+ if kw.get("timeout_ms") is not None:
877
+ out["timeout"] = kw["timeout_ms"]
878
+ if kw.get("batch_ms") is not None:
879
+ out["batch_ms"] = kw["batch_ms"]
880
+ if kw.get("hfactor") is not None:
881
+ out["hfactor"] = kw["hfactor"]
882
+ return out