@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.
- package/LICENSE.txt +49 -0
- package/README.md +33 -2
- package/ThirdPartyNotices.txt +4685 -0
- package/package.json +30 -4
- package/provider-clis.json +1211 -0
- package/python/XACTOR.md +146 -0
- package/python/browser_actor_host.py +456 -0
- package/python/cdp_page.py +363 -0
- package/python/harness_check.py +77 -0
- package/python/requirements.txt +1 -0
- package/python/test_line_endings.py +185 -0
- package/python/xactor/__init__.py +18 -0
- package/python/xactor/__main__.py +226 -0
- package/python/xactor/api.py +882 -0
- package/python/xactor/bridge.py +784 -0
- package/python/xactor/dom.py +117 -0
- package/python/xactor/man/add_card.md +89 -0
- package/python/xactor/man/agent-vs-fork.md +108 -0
- package/python/xactor/man/agent.md +333 -0
- package/python/xactor/man/ask_model.md +110 -0
- package/python/xactor/man/ask_reference.md +56 -0
- package/python/xactor/man/ask_user.md +103 -0
- package/python/xactor/man/attach_app.md +595 -0
- package/python/xactor/man/attach_ide.md +65 -0
- package/python/xactor/man/attach_ide_surface.md +31 -0
- package/python/xactor/man/await_user.md +214 -0
- package/python/xactor/man/bash.md +142 -0
- package/python/xactor/man/browser-driver.md +137 -0
- package/python/xactor/man/browser_activity.md +53 -0
- package/python/xactor/man/browser_attach.md +84 -0
- package/python/xactor/man/browser_close_tab.md +50 -0
- package/python/xactor/man/browser_detach.md +50 -0
- package/python/xactor/man/browser_exec.md +66 -0
- package/python/xactor/man/browser_focus_tab.md +47 -0
- package/python/xactor/man/browser_hybrid.md +47 -0
- package/python/xactor/man/browser_open.md +50 -0
- package/python/xactor/man/browser_open_tab.md +58 -0
- package/python/xactor/man/browser_plumbing.md +107 -0
- package/python/xactor/man/browser_screenshot.md +56 -0
- package/python/xactor/man/browser_snapshot.md +52 -0
- package/python/xactor/man/browser_tabs.md +67 -0
- package/python/xactor/man/bulletin.md +88 -0
- package/python/xactor/man/capture_page.md +54 -0
- package/python/xactor/man/cd.md +64 -0
- package/python/xactor/man/chess_move.md +53 -0
- package/python/xactor/man/children.md +89 -0
- package/python/xactor/man/close_task.md +14 -0
- package/python/xactor/man/compact.md +75 -0
- package/python/xactor/man/drive.md +140 -0
- package/python/xactor/man/drive_space.md +72 -0
- package/python/xactor/man/driving.md +168 -0
- package/python/xactor/man/edit_file.md +112 -0
- package/python/xactor/man/finding.md +194 -0
- package/python/xactor/man/fork_agent.md +45 -0
- package/python/xactor/man/glob.md +83 -0
- package/python/xactor/man/grep.md +115 -0
- package/python/xactor/man/harness.md +165 -0
- package/python/xactor/man/harness_check.md +70 -0
- package/python/xactor/man/harness_format.md +257 -0
- package/python/xactor/man/highlight.md +66 -0
- package/python/xactor/man/instruct.md +111 -0
- package/python/xactor/man/ls.md +63 -0
- package/python/xactor/man/man.md +86 -0
- package/python/xactor/man/merge_wiz.md +40 -0
- package/python/xactor/man/notebook.md +98 -0
- package/python/xactor/man/overview.md +89 -0
- package/python/xactor/man/permissions.md +137 -0
- package/python/xactor/man/phase.md +150 -0
- package/python/xactor/man/powershell.md +74 -0
- package/python/xactor/man/profiles.md +60 -0
- package/python/xactor/man/project_drive.md +49 -0
- package/python/xactor/man/read_bulletin.md +89 -0
- package/python/xactor/man/read_file.md +90 -0
- package/python/xactor/man/relay.md +216 -0
- package/python/xactor/man/reply.md +72 -0
- package/python/xactor/man/reply_message.md +78 -0
- package/python/xactor/man/report_bug.md +118 -0
- package/python/xactor/man/request_answer.md +142 -0
- package/python/xactor/man/request_merge_decisions.md +121 -0
- package/python/xactor/man/request_operator_action.md +152 -0
- package/python/xactor/man/request_review.md +122 -0
- package/python/xactor/man/request_user_action.md +126 -0
- package/python/xactor/man/return_card.md +71 -0
- package/python/xactor/man/run.md +99 -0
- package/python/xactor/man/sandbox.md +144 -0
- package/python/xactor/man/session_fork.md +74 -0
- package/python/xactor/man/session_harness.md +109 -0
- package/python/xactor/man/session_join.md +102 -0
- package/python/xactor/man/session_land.md +73 -0
- package/python/xactor/man/session_nudge.md +73 -0
- package/python/xactor/man/session_rest.md +73 -0
- package/python/xactor/man/session_scope.md +84 -0
- package/python/xactor/man/session_terminate.md +58 -0
- package/python/xactor/man/session_token.md +78 -0
- package/python/xactor/man/sessions.md +93 -0
- package/python/xactor/man/set_harness.md +15 -0
- package/python/xactor/man/spawn_swarm.md +80 -0
- package/python/xactor/man/submit_evidence.md +54 -0
- package/python/xactor/man/swarm_brief.md +30 -0
- package/python/xactor/man/task_status.md +82 -0
- package/python/xactor/man/tasks.md +47 -0
- package/python/xactor/man/todo_write.md +104 -0
- package/python/xactor/man/update_card.md +70 -0
- package/python/xactor/man/use_profile.md +90 -0
- package/python/xactor/man/use_widget.md +82 -0
- package/python/xactor/man/wait_for_task.md +105 -0
- package/python/xactor/man/widget-model.md +284 -0
- package/python/xactor/man/widgets.md +59 -0
- package/python/xactor/man/write_file.md +76 -0
- package/python/xactor/man/writing-a-widget.md +142 -0
- package/python/xactor/man/xylon.md +120 -0
- package/python/xactor/man/xylon_blame.md +80 -0
- package/python/xactor/man/xylon_read.md +131 -0
- package/python/xactor/man/xylon_search.md +125 -0
- package/python/xactor/manual.py +195 -0
- package/python/xactor/parent_watch.py +115 -0
- package/python/xactor/plan_mode.py +350 -0
- package/python/xactor/sandbox.py +856 -0
- package/python/xactor/seals.py +438 -0
- package/python/xactor/shell_hints.py +210 -0
- package/python/xactor/states/__init__.py +13 -0
- package/python/xactor/states/base.py +523 -0
- package/python/xactor/states/registry.py +274 -0
- package/python/xactor/stdlib_api.py +6345 -0
- package/python/xactor/widgets/__init__.py +1 -0
- package/python/xactor/widgets/github_tokens.py +314 -0
- package/python/xactor/widgets/google_aistudio.py +365 -0
- package/settings-schema.json +403 -0
- package/viewer/index.css +160 -0
- package/viewer/index.js +15 -0
- package/wgx.mjs +290659 -0
package/python/XACTOR.md
ADDED
|
@@ -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())
|