@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,146 @@
1
+ ## Requires Python 3.10+
2
+
3
+ This tree uses PEP 604 unions (`dict | None`) in signatures Python evaluates at
4
+ definition time, so 3.9 cannot even import `bridge`. The macOS system
5
+ interpreter at `/usr/bin/python3` **is** 3.9 — the extension never spawns a bare
6
+ `python3` for that reason. `resolvePythonBin` (`src/ide-widgets/widgexactor/pythonBin.ts`)
7
+ picks the interpreter and enforces the floor; `widgex.pythonPath` overrides it.
8
+
9
+ # What the XACTOR is
10
+
11
+ Three layers that let an agent drive a website it has never seen, without
12
+ looking at pictures of it.
13
+
14
+ The problem being solved is not "how do I click a button". It is that **the
15
+ agent knows what it wants and cannot find it**, because the UI is graphical and
16
+ the agent is not. Everything below follows from that.
17
+
18
+ ---
19
+
20
+ # Invariant intent
21
+
22
+ These are binding. Code that violates one is wrong even if it works.
23
+
24
+ ## Layer 0 — actuation
25
+
26
+ Receives serial actions whose intent the agent has **already formed**. Resolves
27
+ each descriptor at dispatch time, performs it with humanised input, and reports
28
+ what happened.
29
+
30
+ - It **searches** — it will rank candidates against a description.
31
+ - It **never chooses** between candidates that are genuinely close. Ambiguity is
32
+ returned with its scores, not resolved by guessing. The decision margin is the
33
+ caller's parameter.
34
+ - **An agent must never need to understand Layer 0 to drive a website.** No
35
+ concept from this layer — coordinates, bezier paths, network classes, epochs —
36
+ should ever be something an agent has to reason about.
37
+ - Every action settles. There is no code path that accepts work and then
38
+ discards it, and no failure that is logged instead of returned.
39
+
40
+ ## Layer 1 — elementary interactions
41
+
42
+ The composable set the agent uses to turn a general intuition about identifiable
43
+ features into a concrete reference. `python/xactor/api.py`.
44
+
45
+ - Its job is **composition**: coarse look, then narrow by scope, then narrow by
46
+ predicate, then act.
47
+ - **The information Layer 1 shows the agent must be consistent with how Layer 0
48
+ resolves.** Same vocabulary, same matching rules, same names. An agent reading
49
+ one model and acting through another is the bug this rule exists to prevent.
50
+ - Locating and acting are separate verbs, because which candidate was meant is
51
+ the agent's call.
52
+
53
+ ## Layer 2 — widgets
54
+
55
+ Caches Layer 1 compositions into declared transitions, so a modelled site can be
56
+ driven to an interactive objective in a single pass with the least cognitive
57
+ effort — explicitly written down, and built to be reached rather than explored.
58
+
59
+ ## Cross-cutting — keep the agent out of the loop
60
+
61
+ Every design choice that adds an agent round trip is a cost that has to be
62
+ justified. A screenshot to look at, a checkpoint mid-chain, an observe-after-act
63
+ — each one turns a script back into a conversation. Load the whole chain, let it
64
+ run, read one report.
65
+
66
+ ---
67
+
68
+ # How it actually works
69
+
70
+ ## Referring to an element
71
+
72
+ A **uid** (`e7f3a1`) or a **path**:
73
+
74
+ ```
75
+ main/table/row"prod-key"/button"Delete"
76
+ dialog"Create Token"/button"Create"
77
+ form#login/textbox"Username"
78
+ ```
79
+
80
+ Paths run through an *anchor-projected* tree — landmarks, collections and their
81
+ members, interactive leaves, headings. Wrapper `div`s are transparent, so a
82
+ framework inserting one changes no path.
83
+
84
+ Nothing is written into the page. A reference is a **description**, re-derived
85
+ from the live DOM by the same function that produced it, so a stale one matches
86
+ nothing or matches several — it can never quietly resolve to the wrong element.
87
+
88
+ *A uid is a memory. A path is an assertion.*
89
+
90
+ ## Finding something
91
+
92
+ ```python
93
+ # see the candidates, with their scores
94
+ cands = await api.search(role="button", like="create")
95
+
96
+ # exactly one, or an error carrying what tied
97
+ btn = await api.one(role="button", textExact="Create", under='dialog"Create Token"')
98
+
99
+ # a search deferred into an action -- the usual form
100
+ click(api.find(role="button", like="create token"))
101
+ ```
102
+
103
+ The UI is graphical, so structure is not always enough. Anchor on what is
104
+ visible instead:
105
+
106
+ ```python
107
+ api.find(role="textbox", right_of="Name")
108
+ api.find(role="button", in_row='row"prod-key"')
109
+ api.find(role="button", below='heading"Danger zone"')
110
+ ```
111
+
112
+ A screenshot is the last resort, not the first. It costs a round trip through
113
+ the agent for every lookup.
114
+
115
+ ## Driving a chain
116
+
117
+ ```python
118
+ click(api.find(role="button", like="create token"))
119
+ type_text(api.find(role="textbox", right_of="Name"), "ci-deploy")
120
+ click(api.one(role="button", textExact="Create", under='dialog"Create Token"'))
121
+ report = await api.drain()
122
+ ```
123
+
124
+ One report for the whole chain: what each step resolved to, whether it worked,
125
+ and what changed. **A failure stops the chain** — the remainder is reported as
126
+ skipped rather than attempted, because once a step fails the page is no longer
127
+ in the state the rest was written against.
128
+
129
+ ## Readiness
130
+
131
+ Not a concept the agent handles. An action waits for its own target; the engine
132
+ knows what is still loading from events it already receives, and only blocks on
133
+ things that could move what is about to be clicked. A stylesheet blocks. An
134
+ image does not.
135
+
136
+ ---
137
+
138
+ # What was removed, and why
139
+
140
+ | Gone | Because |
141
+ |---|---|
142
+ | `xh_N` ids | Page-resident counters. A re-render destroyed the node and the id silently dangled — or worse, matched whatever was minted into that slot next. |
143
+ | `expects_transition` | Its detection required the URL to change, which a modal or an in-place SPA update never does. It burned a hard 5s floor and then reported success anyway. Readiness is intrinsic now. |
144
+ | `hybrid_snapshot` / `query` | A flat list. Eight identical "Delete" buttons were indistinguishable, and every node carried five raw CSS strings nobody read. |
145
+ | The 5-strategy resolver cascade | Blind guessing, one CDP round trip per rung, implemented four times in four places that disagreed. The guessing is now an explicit ranked search the agent can see. |
146
+ | Adaptive timeouts | They recorded their own failures as samples, so a page that timed out at 5s taught the engine to wait 7.5s, then 11.25s, without bound. |
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ browser_actor_host.py — Persistent Python daemon for browser automation.
4
+
5
+ Reads JSON-RPC requests from stdin, executes commands against a browser page
6
+ via raw CDP (WebSocket), writes JSON responses to stdout.
7
+
8
+ Protocol:
9
+ Request: {"id": 1, "method": "execute", "params": {"script": "...", "timeout": 30}}
10
+ Response: {"id": 1, "result": {...}} or {"id": 1, "error": {"message": "..."}}
11
+ Event: {"event": "ready"} | {"event": "navigation", "url": "...", "title": "..."}
12
+
13
+ Methods:
14
+ create_session(ws_url) — Connect to a CDP page target via WebSocket
15
+ stop_session() — Close CDP connection
16
+ execute(script, timeout?) — Run Python code with `page`, `result` in scope
17
+ get_state() — Return {url, title}
18
+ screenshot() — Return {base64} PNG screenshot
19
+ navigate(url) — Navigate to a URL
20
+ start_recording() — Enable action recording, clear buffer
21
+ stop_recording() — Disable recording, return buffer
22
+ flush_recording() — Return + clear buffer without stopping
23
+ shutdown() — Close connection and exit
24
+ """
25
+
26
+ import ast
27
+ import asyncio
28
+ import base64
29
+ import json
30
+ import sys
31
+ import time
32
+ import traceback
33
+ from typing import Any
34
+
35
+ from cdp_page import CdpPage
36
+
37
+
38
+ # ── Injected recorder JS ──────────────────────────────────────────────────────
39
+ # Captures click, input, and submit events in the page and emits them as
40
+ # structured JSON via console.log with a __WX_ACTION__ prefix.
41
+
42
+ RECORDER_JS = """
43
+ (function() {
44
+ if (window.__wx_recorder_installed) return;
45
+ window.__wx_recorder_installed = true;
46
+
47
+ function getAttributes(el) {
48
+ const attrs = {};
49
+ for (const name of ['data-testid', 'aria-label', 'name', 'type', 'href', 'role', 'placeholder']) {
50
+ const val = el.getAttribute(name);
51
+ if (val != null) attrs[name] = val;
52
+ }
53
+ return attrs;
54
+ }
55
+
56
+ function getSelector(el) {
57
+ if (el.id) return '#' + el.id;
58
+ const testId = el.getAttribute('data-testid');
59
+ if (testId) return '[data-testid="' + testId + '"]';
60
+ const ariaLabel = el.getAttribute('aria-label');
61
+ if (ariaLabel) return '[aria-label="' + ariaLabel + '"]';
62
+ const name = el.getAttribute('name');
63
+ if (name) return el.tagName.toLowerCase() + '[name="' + name + '"]';
64
+ return null;
65
+ }
66
+
67
+ // Click handler
68
+ document.addEventListener('click', function(e) {
69
+ const el = e.target;
70
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
71
+ const text = (el.innerText || '').trim().substring(0, 80);
72
+ const action = {
73
+ type: 'click',
74
+ timestamp: Date.now(),
75
+ x: e.clientX,
76
+ y: e.clientY,
77
+ tag: tag,
78
+ id: el.id || null,
79
+ classes: el.className ? String(el.className).substring(0, 200) : null,
80
+ text: text || null,
81
+ selector: getSelector(el),
82
+ attributes: getAttributes(el)
83
+ };
84
+ console.log('__WX_ACTION__' + JSON.stringify(action));
85
+ }, true);
86
+
87
+ // Input handler (debounced — fires on change, not per keystroke)
88
+ document.addEventListener('change', function(e) {
89
+ const el = e.target;
90
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
91
+ // Skip password fields
92
+ if (el.type === 'password') return;
93
+ const action = {
94
+ type: 'input',
95
+ timestamp: Date.now(),
96
+ tag: tag,
97
+ id: el.id || null,
98
+ name: el.getAttribute('name') || null,
99
+ value: (el.value || '').substring(0, 500),
100
+ inputType: el.type || null,
101
+ selector: getSelector(el),
102
+ attributes: getAttributes(el)
103
+ };
104
+ console.log('__WX_ACTION__' + JSON.stringify(action));
105
+ }, true);
106
+
107
+ // Submit handler
108
+ document.addEventListener('submit', function(e) {
109
+ const form = e.target;
110
+ const action = {
111
+ type: 'submit',
112
+ timestamp: Date.now(),
113
+ formId: form.id || null,
114
+ formAction: form.action || null,
115
+ selector: form.id ? '#' + form.id : null
116
+ };
117
+ console.log('__WX_ACTION__' + JSON.stringify(action));
118
+ }, true);
119
+ })();
120
+ """
121
+
122
+
123
+ def emit_event(event_type: str, **kwargs):
124
+ msg = {"event": event_type, **kwargs}
125
+ sys.stdout.write(json.dumps(msg) + "\n")
126
+ sys.stdout.flush()
127
+
128
+
129
+ def emit_response(req_id: Any, result: Any = None, error: str | None = None):
130
+ if error:
131
+ msg = {"id": req_id, "error": {"message": error}}
132
+ else:
133
+ msg = {"id": req_id, "result": result}
134
+ sys.stdout.write(json.dumps(msg) + "\n")
135
+ sys.stdout.flush()
136
+
137
+
138
+ async def main():
139
+ page: CdpPage | None = None
140
+
141
+ # ── Action recording state ─────────────────────────────────────
142
+ recorded_actions: list[dict] = []
143
+ recording_enabled: bool = True
144
+
145
+ def on_console_message(params: dict):
146
+ """CDP Runtime.consoleAPICalled handler — intercepts __WX_ACTION__ logs."""
147
+ if not recording_enabled:
148
+ return
149
+ try:
150
+ args = params.get("args", [])
151
+ if not args:
152
+ return
153
+ text = args[0].get("value", "")
154
+ if isinstance(text, str) and text.startswith("__WX_ACTION__"):
155
+ payload = json.loads(text[len("__WX_ACTION__"):])
156
+ recorded_actions.append(payload)
157
+ except (json.JSONDecodeError, KeyError, IndexError):
158
+ pass
159
+
160
+ def on_network_request(params: dict):
161
+ """CDP Network.requestWillBeSent handler — captures document navigations."""
162
+ if not recording_enabled:
163
+ return
164
+ try:
165
+ req_type = params.get("type", "")
166
+ if req_type == "Document":
167
+ url = params.get("request", {}).get("url", "")
168
+ recorded_actions.append({
169
+ "type": "navigation",
170
+ "url": url,
171
+ "timestamp": int(time.time() * 1000),
172
+ })
173
+ except (KeyError, AttributeError):
174
+ pass
175
+
176
+ async def setup_recording(p: CdpPage):
177
+ """Set up CDP listeners for action recording."""
178
+ try:
179
+ p.on("Runtime.consoleAPICalled", on_console_message)
180
+ p.on("Network.requestWillBeSent", on_network_request)
181
+
182
+ # Inject recorder into all new documents
183
+ await p.send("Page.addScriptToEvaluateOnNewDocument", {
184
+ "source": RECORDER_JS
185
+ })
186
+
187
+ # Inject into current page
188
+ await p.evaluate(RECORDER_JS)
189
+ except Exception as e:
190
+ emit_event("warning", message=f"Recording setup failed: {e}")
191
+
192
+ async def handle_create_session(req_id: Any, params: dict):
193
+ """Connect to a CDP page target via its WebSocket URL."""
194
+ nonlocal page
195
+
196
+ ws_url = params.get("ws_url", "")
197
+ if not ws_url:
198
+ emit_response(req_id, error="ws_url is required")
199
+ return
200
+
201
+ try:
202
+ emit_event("log", message=f"Connecting to CDP target: {ws_url}")
203
+ p = CdpPage(ws_url)
204
+ await p.connect()
205
+ page = p
206
+
207
+ emit_event("log", message=f"Connected. URL: {p.url}")
208
+
209
+ # Set up recording
210
+ await setup_recording(p)
211
+
212
+ emit_response(req_id, result={
213
+ "session_id": "cdp",
214
+ "status": "connected",
215
+ "mode": "cdp",
216
+ })
217
+ except Exception:
218
+ emit_response(req_id, error=traceback.format_exc())
219
+
220
+ async def handle_stop_session(req_id: Any):
221
+ """Close CDP connection."""
222
+ nonlocal page
223
+
224
+ try:
225
+ if page:
226
+ await page.close()
227
+ page = None
228
+ emit_response(req_id, result={"status": "stopped"})
229
+ except Exception:
230
+ emit_response(req_id, error=traceback.format_exc())
231
+
232
+ async def get_page():
233
+ """Get the current CdpPage."""
234
+ if page is None:
235
+ emit_event("error", message="No browser session. Call create_session first.")
236
+ raise RuntimeError("No browser session")
237
+ return page
238
+
239
+ async def handle_execute(req_id: Any, params: dict):
240
+ nonlocal recorded_actions
241
+ script = params.get("script", "")
242
+ timeout = params.get("timeout", 60)
243
+
244
+ try:
245
+ p = await get_page()
246
+ except RuntimeError as e:
247
+ emit_response(req_id, error=str(e))
248
+ return
249
+
250
+ # Mark script start in recording buffer
251
+ script_start_idx = len(recorded_actions)
252
+ if recording_enabled:
253
+ recorded_actions.append({"type": "script_start", "timestamp": int(time.time() * 1000)})
254
+
255
+ result_dict: dict[str, Any] = {}
256
+ scope = {
257
+ "page": p,
258
+ "result": result_dict,
259
+ "asyncio": asyncio,
260
+ }
261
+
262
+ try:
263
+ # Wrapped on the AST rather than by indenting the source, for the
264
+ # reason `xactor/bridge.py`'s `compile_exec` gives at length: text
265
+ # indentation reaches inside triple-quoted strings and silently
266
+ # adds four spaces to every line of the caller's own data.
267
+ module = ast.parse("async def __actor_main__():\n return result")
268
+ module.body[0].body[:0] = ast.parse(script).body
269
+ ast.fix_missing_locations(module)
270
+ exec(compile(module, "<actor-script>", "exec"), scope)
271
+ await asyncio.wait_for(scope["__actor_main__"](), timeout=timeout)
272
+ except asyncio.TimeoutError:
273
+ emit_response(req_id, error=f"Script timed out after {timeout}s")
274
+ return
275
+ except Exception:
276
+ emit_response(req_id, error=traceback.format_exc())
277
+ return
278
+
279
+ # Mark script end
280
+ if recording_enabled:
281
+ recorded_actions.append({"type": "script_end", "timestamp": int(time.time() * 1000)})
282
+
283
+ script_actions = recorded_actions[script_start_idx:] if recording_enabled else []
284
+
285
+ try:
286
+ current_url = p.url
287
+ current_title = await asyncio.wait_for(p.title(), timeout=5)
288
+ except Exception:
289
+ current_url = p.url
290
+ current_title = ""
291
+
292
+ response = {
293
+ "data": result_dict,
294
+ "url": current_url,
295
+ "title": current_title,
296
+ }
297
+ if recording_enabled and script_actions:
298
+ response["recorded_actions"] = script_actions
299
+
300
+ emit_response(req_id, result=response)
301
+
302
+ async def handle_get_state(req_id: Any):
303
+ try:
304
+ p = await get_page()
305
+ except RuntimeError as e:
306
+ emit_response(req_id, error=str(e))
307
+ return
308
+ try:
309
+ url = p.url
310
+ title = await asyncio.wait_for(p.title(), timeout=5)
311
+ except Exception:
312
+ url = p.url if p else ""
313
+ title = ""
314
+ emit_response(req_id, result={"url": url, "title": title})
315
+
316
+ async def handle_screenshot(req_id: Any):
317
+ try:
318
+ p = await get_page()
319
+ except RuntimeError as e:
320
+ emit_response(req_id, error=str(e))
321
+ return
322
+ try:
323
+ png_bytes = await p.screenshot()
324
+ b64 = base64.b64encode(png_bytes).decode("ascii")
325
+ emit_response(req_id, result={"base64": b64})
326
+ except Exception:
327
+ emit_response(req_id, error=traceback.format_exc())
328
+
329
+ async def handle_navigate(req_id: Any, params: dict):
330
+ url = params.get("url", "")
331
+ try:
332
+ p = await get_page()
333
+ except RuntimeError as e:
334
+ emit_response(req_id, error=str(e))
335
+ return
336
+ try:
337
+ await p.goto(url)
338
+ current_url = p.url
339
+ current_title = await p.title()
340
+ emit_event("navigation", url=current_url, title=current_title)
341
+ emit_response(req_id, result={"url": current_url, "title": current_title})
342
+ except Exception:
343
+ emit_response(req_id, error=traceback.format_exc())
344
+
345
+ async def handle_start_recording(req_id: Any):
346
+ nonlocal recording_enabled, recorded_actions
347
+ recording_enabled = True
348
+ recorded_actions = []
349
+ emit_response(req_id, result={"status": "recording"})
350
+
351
+ async def handle_stop_recording(req_id: Any):
352
+ nonlocal recording_enabled
353
+ recording_enabled = False
354
+ actions = list(recorded_actions)
355
+ emit_response(req_id, result={"recorded_actions": actions, "count": len(actions)})
356
+
357
+ async def handle_flush_recording(req_id: Any):
358
+ nonlocal recorded_actions
359
+ actions = list(recorded_actions)
360
+ recorded_actions = []
361
+ emit_response(req_id, result={"recorded_actions": actions, "count": len(actions)})
362
+
363
+ async def handle_shutdown(req_id: Any):
364
+ nonlocal page
365
+
366
+ if page:
367
+ try:
368
+ await page.close()
369
+ except Exception:
370
+ pass
371
+ page = None
372
+
373
+ emit_response(req_id, result={"status": "shutdown"})
374
+
375
+ # Main stdin read loop
376
+ loop = asyncio.get_event_loop()
377
+
378
+ if sys.platform == "win32":
379
+ import concurrent.futures
380
+ _stdin_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="stdin")
381
+
382
+ def _read_stdin_line() -> bytes:
383
+ """Blocking read of one line from stdin (runs in thread)."""
384
+ return sys.stdin.buffer.readline()
385
+
386
+ async def _readline() -> bytes:
387
+ return await loop.run_in_executor(_stdin_executor, _read_stdin_line)
388
+ else:
389
+ reader = asyncio.StreamReader()
390
+ protocol = asyncio.StreamReaderProtocol(reader)
391
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
392
+
393
+ async def _readline() -> bytes:
394
+ return await reader.readline()
395
+
396
+ # Signal ready
397
+ sys.stdout.write(json.dumps({"event": "ready"}) + "\n")
398
+ sys.stdout.flush()
399
+
400
+ while True:
401
+ line = await _readline()
402
+ if not line:
403
+ break # stdin closed
404
+
405
+ line_str = line.decode("utf-8").strip()
406
+ if not line_str:
407
+ continue
408
+
409
+ try:
410
+ req = json.loads(line_str)
411
+ except json.JSONDecodeError as e:
412
+ sys.stdout.write(json.dumps({"id": None, "error": {"message": f"Invalid JSON: {e}"}}) + "\n")
413
+ sys.stdout.flush()
414
+ continue
415
+
416
+ req_id = req.get("id")
417
+ method = req.get("method", "")
418
+ params = req.get("params", {})
419
+
420
+ try:
421
+ if method == "create_session":
422
+ await handle_create_session(req_id, params)
423
+ elif method == "stop_session":
424
+ await handle_stop_session(req_id)
425
+ elif method == "execute":
426
+ await handle_execute(req_id, params)
427
+ elif method == "get_state":
428
+ await handle_get_state(req_id)
429
+ elif method == "screenshot":
430
+ await handle_screenshot(req_id)
431
+ elif method == "navigate":
432
+ await handle_navigate(req_id, params)
433
+ elif method == "start_recording":
434
+ await handle_start_recording(req_id)
435
+ elif method == "stop_recording":
436
+ await handle_stop_recording(req_id)
437
+ elif method == "flush_recording":
438
+ await handle_flush_recording(req_id)
439
+ elif method == "shutdown":
440
+ await handle_shutdown(req_id)
441
+ break
442
+ else:
443
+ emit_response(req_id, error=f"Unknown method: {method}")
444
+ except Exception:
445
+ emit_response(req_id, error=traceback.format_exc())
446
+
447
+ # Cleanup
448
+ if page:
449
+ try:
450
+ await page.close()
451
+ except Exception:
452
+ pass
453
+
454
+
455
+ if __name__ == "__main__":
456
+ asyncio.run(main())