browser-workspace 0.4.2 → 0.4.9
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/README.md +15 -1
- package/SKILL.md +22 -21
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/package.json +1 -1
- package/platform_runner.py +11 -2
- package/platforms/chatgpt/actions/_lifecycle_common.py +73 -20
- package/platforms/chatgpt/actions/_new_turn.py +1 -0
- package/platforms/chatgpt/actions/_readiness.py +29 -3
- package/platforms/chatgpt/actions/_submit_thread.py +20 -3
- package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
- package/platforms/chatgpt/actions/_thread_turn.py +31 -2
- package/platforms/chatgpt/tests/test_contract.py +114 -1
- package/platforms/iyf/AIRPLAY.md +25 -0
- package/platforms/iyf/actions/_find.py +18 -0
- package/platforms/iyf/actions/_open.py +10 -0
- package/platforms/iyf/manifest.yaml +11 -0
- package/platforms/iyf/safari-airplay +57 -0
- package/pyproject.toml +6 -2
- package/session_client.py +10 -0
- package/session_daemon.py +91 -3
- package/src/browser_workspace.egg-info/PKG-INFO +2 -1
- package/src/browser_workspace.egg-info/SOURCES.txt +91 -15
- package/src/browser_workspace.egg-info/entry_points.txt +2 -0
- package/src/browser_workspace.egg-info/requires.txt +1 -0
- package/src/browser_workspace.egg-info/top_level.txt +1 -1
- package/src/browser_workspace_app/__init__.py +1 -0
- package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
- package/src/browser_workspace_app/cli.py +236 -0
- package/src/browser_workspace_app/extension/manifest.json +26 -0
- package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
- package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
- package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
- package/src/browser_workspace_app/extension/workspace.html +8 -0
- package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
- package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
- package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
- package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
- package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
- package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
- package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
- package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
- package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
- package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
- package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
- package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
- package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
- package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
- package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
- package/src/browser_workspace_app/managed_chrome.py +244 -0
- package/src/browser_workspace_app/node/admin.mjs +44 -0
- package/src/browser_workspace_app/package.json +36 -0
- package/src/browser_workspace_app/pyproject.toml +33 -0
- package/src/browser_workspace_app/session_client.py +117 -0
- package/src/browser_workspace_app/session_daemon.py +431 -0
- package/src/browser_harness/__init__.py +0 -2
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +0 -201
- package/src/browser_harness/admin.py +0 -1588
- package/src/browser_harness/auth.py +0 -546
- package/src/browser_harness/daemon.py +0 -894
- package/src/browser_harness/helpers.py +0 -668
- package/src/browser_harness/macos.py +0 -131
- package/src/browser_harness/mcp_cli.py +0 -16
- package/src/browser_harness/paths.py +0 -49
- package/src/browser_harness/recorder.py +0 -343
- package/src/browser_harness/run.py +0 -413
- package/src/browser_harness/telemetry.py +0 -308
- package/src/browser_harness/video-template.html +0 -970
- package/src/browser_harness/video.py +0 -749
- package/src/browser_harness/video_render.py +0 -524
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ browser-workspace session stop SESSION_ID
|
|
|
26
26
|
|
|
27
27
|
```text
|
|
28
28
|
browser-workspace
|
|
29
|
-
├──
|
|
29
|
+
├── pyproject.toml pins the external browser-harness runtime dependency
|
|
30
30
|
├── agent-workspace/ Browser Workspace helper layer
|
|
31
31
|
├── extension/ Chrome workspace/tab manager
|
|
32
32
|
├── session_daemon.py persistent Python session service
|
|
@@ -136,3 +136,17 @@ Each platform manifest records explicit verification status and evidence. `verif
|
|
|
136
136
|
## npx skills installation
|
|
137
137
|
|
|
138
138
|
After `npx skills add ...`, invoke `~/.agents/skills/browser-workspace/bin/browser-workspace` directly. The first session start bootstraps the skill-owned Python virtual environment automatically; running `scripts/install.sh` is optional.
|
|
139
|
+
|
|
140
|
+
### Python distribution (PyPI)
|
|
141
|
+
|
|
142
|
+
Browser Workspace is also packaged as a Python CLI. Once its PyPI trusted publisher is configured and a `pypi-v*` release tag is published, invoke it with:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
uvx browser-workspace status
|
|
146
|
+
uvx browser-workspace session list
|
|
147
|
+
uvx browser-workspace platform profile wechat-channels
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The package entry point is `browser_workspace_app.cli:main`, and Python dependencies are managed by `pyproject.toml`. This uses the same existing Chrome profile and Session Daemon socket as the previous npm CLI; it does **not** start a second browser or daemon when the existing one is healthy.
|
|
151
|
+
|
|
152
|
+
Before building, run `python tools/sync-pypi-package.py` to copy canonical Python and packaged platform assets to the wheel source. Run `uv build`, then validate with `uvx --from dist/browser_workspace-<version>-py3-none-any.whl browser-workspace status`. The PyPI publishing workflow uses GitHub OIDC trusted publishing; configure `lalalic/browser-workspace` with the `pypi` environment as the publisher on PyPI before the first tag release.
|
package/SKILL.md
CHANGED
|
@@ -9,14 +9,14 @@ Browser Workspace is the **only browser skill agents should load**. It includes
|
|
|
9
9
|
|
|
10
10
|
## Agent contract
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Install the Skill instructions through your preferred skill manager, but run the published Python CLI through **PyPI/uvx**. The Skill directory is documentation, not the executable source of truth:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
bw() { uvx browser-workspace "$@"; }
|
|
16
|
+
bw status
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
On first use, Browser Workspace
|
|
19
|
+
On first use, `uvx` installs Browser Workspace and Python dependencies from PyPI into its managed cache. No local source checkout, `.venv`, `.env`, or `npx` CLI is required. Use `uvx browser-workspace ...` consistently, including in NeoY/Agents Relay workers.
|
|
20
20
|
|
|
21
21
|
Use one session-owned execution path for orchestrated browser work:
|
|
22
22
|
|
|
@@ -61,7 +61,7 @@ Rules:
|
|
|
61
61
|
Default workspace is `Harness`:
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
|
-
|
|
64
|
+
bw session start
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
Normal agents do not choose or manage workspaces. `session start` defaults to `Harness`; the `--workspace` implementation option is hidden from normal CLI help. Workspace lifecycle is not exposed through the agent CLI. Product runtimes that need persistent isolation discover the product-only Node admin helper with `browser-workspace status`, then call `ensureWorkspace` / `deleteWorkspace` through that helper.
|
|
@@ -69,7 +69,7 @@ Normal agents do not choose or manage workspaces. `session start` defaults to `H
|
|
|
69
69
|
Start directly at a URL when known:
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
-
|
|
72
|
+
bw session start --url https://example.com
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
The result is JSON containing `session_id`, `workspace`, `target_id`, the starting `url`, and `workspace_supported`.
|
|
@@ -81,7 +81,7 @@ Browser Workspace self-manages exactly one machine-wide persistent, visible Chro
|
|
|
81
81
|
`session exec` reads Python from stdin. Helpers are already imported; do not import another browser package.
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
|
|
84
|
+
bw session exec <session_id> <<'PY'
|
|
85
85
|
print(page_info())
|
|
86
86
|
print(snapshot())
|
|
87
87
|
PY
|
|
@@ -90,7 +90,7 @@ PY
|
|
|
90
90
|
Python state persists across later calls in the same session:
|
|
91
91
|
|
|
92
92
|
```bash
|
|
93
|
-
|
|
93
|
+
bw session exec <session_id> <<'PY'
|
|
94
94
|
meeting_name = "Demo"
|
|
95
95
|
print(current_tab())
|
|
96
96
|
PY
|
|
@@ -99,7 +99,7 @@ PY
|
|
|
99
99
|
Then later:
|
|
100
100
|
|
|
101
101
|
```bash
|
|
102
|
-
|
|
102
|
+
bw session exec <session_id> <<'PY'
|
|
103
103
|
print(meeting_name)
|
|
104
104
|
PY
|
|
105
105
|
```
|
|
@@ -109,7 +109,7 @@ A Python file may also be used with `--code-file`.
|
|
|
109
109
|
## Stop
|
|
110
110
|
|
|
111
111
|
```bash
|
|
112
|
-
|
|
112
|
+
bw session stop <session_id>
|
|
113
113
|
```
|
|
114
114
|
|
|
115
115
|
Stopping closes every tab owned or opened by that session—including tabs created with `new_tab()`, `bh.new_tab()`, and detected child/popup tabs—then destroys the session's Python namespace. Raw `cdp("Target.createTarget", ...)` is rejected; use `new_tab()` so Browser Workspace can own and group the tab. It does not close unrelated browser tabs. Do not reuse the ID afterward.
|
|
@@ -117,11 +117,11 @@ Stopping closes every tab owned or opened by that session—including tabs creat
|
|
|
117
117
|
A safe shell structure is:
|
|
118
118
|
|
|
119
119
|
```bash
|
|
120
|
-
SESSION_JSON=$(
|
|
120
|
+
SESSION_JSON=$(bw session start)
|
|
121
121
|
SESSION_ID=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["session_id"])' "$SESSION_JSON")
|
|
122
|
-
trap '
|
|
122
|
+
trap 'bw session stop "$SESSION_ID" >/dev/null 2>&1 || true' EXIT
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
bw session exec "$SESSION_ID" <<'PY'
|
|
125
125
|
print(snapshot())
|
|
126
126
|
PY
|
|
127
127
|
```
|
|
@@ -231,23 +231,23 @@ Platform rules:
|
|
|
231
231
|
Normal orchestrator workflow:
|
|
232
232
|
|
|
233
233
|
```bash
|
|
234
|
-
SESSION_JSON=$(
|
|
234
|
+
SESSION_JSON=$(bw session start --url https://example.com)
|
|
235
235
|
SESSION_ID=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["session_id"])' "$SESSION_JSON")
|
|
236
236
|
|
|
237
|
-
|
|
237
|
+
bw platform run microsoft-teams meeting \
|
|
238
238
|
--session-id "$SESSION_ID" \
|
|
239
239
|
--config /path/to/config.json
|
|
240
240
|
|
|
241
241
|
# If it fails: inspect/self-heal using the same SESSION_ID, write the repair
|
|
242
242
|
# back to the platform implementation, then resume safely.
|
|
243
243
|
|
|
244
|
-
|
|
244
|
+
bw session stop "$SESSION_ID"
|
|
245
245
|
```
|
|
246
246
|
|
|
247
247
|
One-shot mode is explicit and is not self-healing:
|
|
248
248
|
|
|
249
249
|
```bash
|
|
250
|
-
|
|
250
|
+
bw platform run microsoft-teams meeting \
|
|
251
251
|
--auto-session \
|
|
252
252
|
--config /path/to/config.json
|
|
253
253
|
```
|
|
@@ -296,13 +296,14 @@ Read the matching platform `README.md` and `manifest.yaml` before invoking or mo
|
|
|
296
296
|
|
|
297
297
|
## Installation
|
|
298
298
|
|
|
299
|
-
|
|
299
|
+
Use the published PyPI command (requires `uv`/`uvx`):
|
|
300
300
|
|
|
301
301
|
```bash
|
|
302
|
-
|
|
302
|
+
uvx browser-workspace status
|
|
303
|
+
uvx browser-workspace session list
|
|
303
304
|
```
|
|
304
305
|
|
|
305
|
-
|
|
306
|
+
`uvx` resolves the published Python wheel and installs the dependencies declared in `pyproject.toml`. Do not run the legacy bundled installer or point agents at a checked-out `bin/browser-workspace` script. The installed CLI reuses the same Browser Workspace Chrome profile and Session Daemon socket.
|
|
306
307
|
|
|
307
308
|
There is no required separate Browser Harness installation.
|
|
308
309
|
|
|
@@ -320,4 +321,4 @@ kgbghhigmbpefppgkocgjgnnnbhjchic
|
|
|
320
321
|
|
|
321
322
|
## Internal provenance
|
|
322
323
|
|
|
323
|
-
The low-level browser runtime is
|
|
324
|
+
The low-level browser runtime is the separately maintained `browser-harness` Python dependency. Browser Workspace owns the session/workspace/platform product layer and pins the exact Harness runtime revision it was tested with; Harness source is not vendored into this repository. Upstream licensing remains documented under `licenses/`.
|
|
Binary file
|
package/package.json
CHANGED
package/platform_runner.py
CHANGED
|
@@ -135,7 +135,11 @@ def action_path(platform: str, action: str) -> Path:
|
|
|
135
135
|
|
|
136
136
|
def prepare_action(path: Path, config_path: Path | None = None) -> str:
|
|
137
137
|
source = path.read_text()
|
|
138
|
-
|
|
138
|
+
# The daemon is long-lived: importing a previous installed platform module
|
|
139
|
+
# must never shadow the current action source in sys.modules.
|
|
140
|
+
siblings = [p.stem for p in path.parent.glob("_*.py")]
|
|
141
|
+
prelude = "import sys\n" + "\n".join(f"sys.modules.pop({name!r}, None)" for name in siblings)
|
|
142
|
+
source = prelude + "\nsys.path.insert(0, " + repr(str(path.parent.resolve())) + ")\n" + source
|
|
139
143
|
if "__CFG_PATH__" in source:
|
|
140
144
|
if config_path is None:
|
|
141
145
|
raise ValueError(f"{path.parent.parent.name}/{path.stem}: --config is required")
|
|
@@ -173,7 +177,12 @@ def run_platform_action(
|
|
|
173
177
|
|
|
174
178
|
owned_session = bool(auto_session)
|
|
175
179
|
started = request({"op": "start", "workspace": None, "url": url}) if owned_session else {"session_id": session_id, "workspace_supported": None}
|
|
176
|
-
sid = started
|
|
180
|
+
sid = started.get("session_id")
|
|
181
|
+
if not sid:
|
|
182
|
+
return {"platform": platform, "action": action, "workspace_supported": started.get("workspace_supported"),
|
|
183
|
+
"session_mode": "auto_session_no_self_heal" if owned_session else "caller_owned_self_heal_capable",
|
|
184
|
+
"self_heal_capable": not owned_session,
|
|
185
|
+
"result": {"ok": False, "error": started.get("error", "session start returned no session_id")}}
|
|
177
186
|
result = None
|
|
178
187
|
cleanup = None
|
|
179
188
|
try:
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import time
|
|
3
|
-
from _readiness import
|
|
3
|
+
from _readiness import (
|
|
4
|
+
SubmissionVerificationError,
|
|
5
|
+
submission_failure_diagnostic,
|
|
6
|
+
submission_receipt,
|
|
7
|
+
prompt_text_matches,
|
|
8
|
+
wait_until_stable,
|
|
9
|
+
)
|
|
4
10
|
|
|
5
11
|
|
|
6
12
|
def composer_selector():
|
|
@@ -12,6 +18,24 @@ def composer_selector():
|
|
|
12
18
|
const r=e.getBoundingClientRect();
|
|
13
19
|
if (r.width>0 && r.height>0) return selector;
|
|
14
20
|
}
|
|
21
|
+
const generic=[...document.querySelectorAll('[contenteditable="true"]')].filter(e=>{
|
|
22
|
+
if (e.id==='pending-home-input' || e.disabled) return false;
|
|
23
|
+
const r=e.getBoundingClientRect();
|
|
24
|
+
return r.width>0 && r.height>0;
|
|
25
|
+
});
|
|
26
|
+
return generic.length===1 ? '[contenteditable="true"]:not(#pending-home-input)' : null;
|
|
27
|
+
})()""")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def primary_composer_selector():
|
|
31
|
+
return js("""(() => {
|
|
32
|
+
const selectors=['#prompt-textarea','[contenteditable="true"][data-composer-markdown]','[contenteditable="true"][data-lexical-editor="true"]'];
|
|
33
|
+
for (const selector of selectors) {
|
|
34
|
+
const e=document.querySelector(selector);
|
|
35
|
+
if (!e || e.disabled) continue;
|
|
36
|
+
const r=e.getBoundingClientRect();
|
|
37
|
+
if (r.width>0 && r.height>0) return selector;
|
|
38
|
+
}
|
|
15
39
|
return null;
|
|
16
40
|
})()""")
|
|
17
41
|
|
|
@@ -33,6 +57,16 @@ def send_enabled():
|
|
|
33
57
|
})()"""))
|
|
34
58
|
|
|
35
59
|
|
|
60
|
+
def verification_ui_state(selector):
|
|
61
|
+
return {
|
|
62
|
+
"composer_selector": selector,
|
|
63
|
+
"composer_text_length": len((composer_text(selector) or "").strip()),
|
|
64
|
+
"send_enabled": send_enabled(),
|
|
65
|
+
"generating": is_generating(),
|
|
66
|
+
"user_turn_count": len(user_turns()),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
36
70
|
def is_generating():
|
|
37
71
|
return bool(js("""(() => !![...document.querySelectorAll('button')].find(b => {
|
|
38
72
|
const a=(b.getAttribute('aria-label')||'').toLowerCase();
|
|
@@ -66,12 +100,21 @@ def wait_for_idle(timeout=300):
|
|
|
66
100
|
|
|
67
101
|
|
|
68
102
|
def fill_prompt(prompt):
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
103
|
+
try:
|
|
104
|
+
selector=wait_until_stable(
|
|
105
|
+
lambda: primary_composer_selector(),
|
|
106
|
+
lambda value: bool(value),
|
|
107
|
+
timeout=20,
|
|
108
|
+
phase="ChatGPT canonical composer readiness",
|
|
109
|
+
)
|
|
110
|
+
except RuntimeError:
|
|
111
|
+
# The preferred selector timed out; still try the generic composer.
|
|
112
|
+
selector=wait_until_stable(
|
|
113
|
+
lambda: composer_selector(),
|
|
114
|
+
lambda value: bool(value),
|
|
115
|
+
timeout=5,
|
|
116
|
+
phase="ChatGPT composer readiness",
|
|
117
|
+
)
|
|
75
118
|
editable=bool(js(f"""(() => document.querySelector({json.dumps(selector)})?.getAttribute('contenteditable')==='true')()"""))
|
|
76
119
|
if editable:
|
|
77
120
|
chunks=[prompt[i:i+256] for i in range(0, len(prompt), 256)]
|
|
@@ -93,27 +136,37 @@ def fill_prompt(prompt):
|
|
|
93
136
|
if not ok: raise RuntimeError("ChatGPT composer rejected lifecycle continuation")
|
|
94
137
|
else:
|
|
95
138
|
fill_input(selector,prompt,clear_first=True)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
139
|
+
try:
|
|
140
|
+
wait_until_stable(
|
|
141
|
+
lambda: {"text":composer_text(selector),"send":send_enabled()},
|
|
142
|
+
lambda state: prompt_text_matches(state["text"],prompt) and state["send"],
|
|
143
|
+
timeout=20,
|
|
144
|
+
phase="ChatGPT lifecycle continuation readiness",
|
|
145
|
+
)
|
|
146
|
+
except RuntimeError as exc:
|
|
147
|
+
raise RuntimeError(f"{exc}; diagnostic={json.dumps(verification_ui_state(selector), ensure_ascii=False)}") from exc
|
|
102
148
|
return selector
|
|
103
149
|
|
|
104
150
|
|
|
105
151
|
def submit(prompt):
|
|
106
|
-
before=len(user_turns())
|
|
107
152
|
selector=fill_prompt(prompt)
|
|
153
|
+
ui_before=verification_ui_state(selector)
|
|
108
154
|
clicked=js("""(() => {
|
|
109
155
|
const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"],form button[type="submit"]');
|
|
110
156
|
if(!b||b.disabled||b.getAttribute('aria-disabled')==='true')return false;
|
|
111
157
|
b.click(); return true;
|
|
112
158
|
})()""")
|
|
113
159
|
if not clicked: raise RuntimeError("ChatGPT send button changed before lifecycle submit")
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
160
|
+
try:
|
|
161
|
+
wait_until_stable(
|
|
162
|
+
lambda: {"composer_empty": not bool(composer_text(selector).strip()), "send_disabled": not send_enabled()},
|
|
163
|
+
lambda state: state["composer_empty"] and state["send_disabled"],
|
|
164
|
+
timeout=30, interval=.2, stable_samples=3,
|
|
165
|
+
phase="post-submit composer reset",
|
|
166
|
+
)
|
|
167
|
+
except RuntimeError as exc:
|
|
168
|
+
raise SubmissionVerificationError(
|
|
169
|
+
"ChatGPT submission did not reach a stable cleared composer and disabled Send button",
|
|
170
|
+
{"ui_before":ui_before,"ui_after":verification_ui_state(selector),"reason":"post_submit_ui_unverified"},
|
|
171
|
+
) from exc
|
|
172
|
+
return {"verified_by":"composer-cleared", "turn": {"id":""}}
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
"""Bounded polling helpers for ChatGPT submit readiness."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import json
|
|
4
5
|
import re
|
|
5
6
|
import time
|
|
6
7
|
|
|
8
|
+
|
|
9
|
+
class SubmissionVerificationError(RuntimeError):
|
|
10
|
+
def __init__(self, message, diagnostic):
|
|
11
|
+
serialized = json.dumps(diagnostic, ensure_ascii=False, sort_keys=True)
|
|
12
|
+
super().__init__(f"{message}: {serialized}")
|
|
13
|
+
self.diagnostic = diagnostic
|
|
14
|
+
|
|
7
15
|
def wait_until_stable(read_state, ready, *, timeout, interval=0.25, stable_samples=2, phase):
|
|
8
16
|
deadline = time.monotonic() + timeout
|
|
9
17
|
stable = 0
|
|
10
18
|
last_state = {}
|
|
19
|
+
stable_state = None
|
|
11
20
|
while time.monotonic() < deadline:
|
|
12
21
|
try:
|
|
13
22
|
last_state = read_state()
|
|
@@ -16,7 +25,11 @@ def wait_until_stable(read_state, ready, *, timeout, interval=0.25, stable_sampl
|
|
|
16
25
|
time.sleep(interval)
|
|
17
26
|
continue
|
|
18
27
|
if ready(last_state):
|
|
19
|
-
|
|
28
|
+
if stable_state == last_state:
|
|
29
|
+
stable += 1
|
|
30
|
+
else:
|
|
31
|
+
stable_state = last_state
|
|
32
|
+
stable = 1
|
|
20
33
|
if stable >= stable_samples:
|
|
21
34
|
return last_state
|
|
22
35
|
else:
|
|
@@ -47,6 +60,19 @@ def submission_receipt(turns, before_count, prompt, composer_text):
|
|
|
47
60
|
for turn in turns[before_count:]:
|
|
48
61
|
if prompt_text_matches(turn.get("text"), prompt):
|
|
49
62
|
return {"verified_by": "user-turn", "turn": turn}
|
|
50
|
-
if not (composer_text or "").strip():
|
|
51
|
-
return {"verified_by": "composer-cleared", "turn": {"id": None, "text": prompt}}
|
|
52
63
|
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def submission_failure_diagnostic(turns, before_count, prompt, composer_text, ui_before=None, ui_after=None):
|
|
67
|
+
return {
|
|
68
|
+
"accepted": False,
|
|
69
|
+
"reason": "no_accepted_user_turn",
|
|
70
|
+
"user_turn_matched": False,
|
|
71
|
+
"before_user_turn_count": len(turns[:before_count]),
|
|
72
|
+
"user_turn_count": len(turns),
|
|
73
|
+
"composer_populated": bool((composer_text or "").strip()),
|
|
74
|
+
"composer_text_length": len((composer_text or "").strip()),
|
|
75
|
+
"ui_changed": None if ui_before is None or ui_after is None else bool(ui_before != ui_after),
|
|
76
|
+
"ui_before": ui_before,
|
|
77
|
+
"ui_after": ui_after,
|
|
78
|
+
}
|
|
@@ -2,7 +2,13 @@ import json
|
|
|
2
2
|
import os
|
|
3
3
|
import time
|
|
4
4
|
from urllib.parse import quote
|
|
5
|
-
from _readiness import
|
|
5
|
+
from _readiness import (
|
|
6
|
+
SubmissionVerificationError,
|
|
7
|
+
submission_failure_diagnostic,
|
|
8
|
+
submission_receipt,
|
|
9
|
+
prompt_text_matches,
|
|
10
|
+
wait_until_stable,
|
|
11
|
+
)
|
|
6
12
|
|
|
7
13
|
CFG=json.load(open("__CFG_PATH__",encoding="utf-8"))
|
|
8
14
|
project_id=str(CFG.get("project_id") or "").strip()
|
|
@@ -117,6 +123,8 @@ wait_until_stable(
|
|
|
117
123
|
timeout=45 if attachments else 20,
|
|
118
124
|
phase="send readiness",
|
|
119
125
|
)
|
|
126
|
+
state_before=composer_state()
|
|
127
|
+
ui_before={"composer_present":state_before["present"],"composer_text_length":len(state_before["text"]),"send_enabled":state_before["sendEnabled"]}
|
|
120
128
|
if not click_visible_send(): raise RuntimeError("ChatGPT send button changed before click")
|
|
121
129
|
deadline=time.time()+15
|
|
122
130
|
receipt=None
|
|
@@ -124,6 +132,15 @@ while time.time()<deadline:
|
|
|
124
132
|
receipt=submission_receipt(user_turns(),before_users,prompt,visible_composer_text())
|
|
125
133
|
if receipt: break
|
|
126
134
|
time.sleep(.1)
|
|
127
|
-
if not receipt:
|
|
135
|
+
if not receipt:
|
|
136
|
+
state_after=composer_state()
|
|
137
|
+
raise SubmissionVerificationError(
|
|
138
|
+
"ChatGPT submission verification did not observe an accepted submission",
|
|
139
|
+
submission_failure_diagnostic(
|
|
140
|
+
user_turns(), before_users, prompt, visible_composer_text(),
|
|
141
|
+
ui_before,
|
|
142
|
+
{"composer_present":state_after["present"],"composer_text_length":len(state_after["text"]),"send_enabled":state_after["sendEnabled"]},
|
|
143
|
+
),
|
|
144
|
+
)
|
|
128
145
|
|
|
129
|
-
print(json.dumps({"status":"submitted","project_id":project_id,"thread_id":thread_id,"verified_by":receipt["verified_by"],"user_message_id":receipt["turn"].get("id") or None,"app":app_name or None},ensure_ascii=False),flush=True)
|
|
146
|
+
print(json.dumps({"status":"submitted","project_id":project_id,"thread_id":thread_id,"thread_url":thread_url,"verified_by":receipt["verified_by"],"user_message_id":receipt["turn"].get("id") or None,"app":app_name or None},ensure_ascii=False),flush=True)
|
|
@@ -439,7 +439,14 @@ def _fill_prompt_if_needed(prompt, mode_label, timeout=30):
|
|
|
439
439
|
deadline = time.time() + timeout
|
|
440
440
|
last_error = None
|
|
441
441
|
while time.time() < deadline:
|
|
442
|
-
|
|
442
|
+
try:
|
|
443
|
+
selector = _wait_for_composer(timeout=min(5, max(1, deadline - time.time())))
|
|
444
|
+
except RuntimeError as exc:
|
|
445
|
+
# Hydration and navigation can temporarily remove the composer.
|
|
446
|
+
# Continue the outer readiness window rather than aborting on the first poll.
|
|
447
|
+
last_error = exc
|
|
448
|
+
time.sleep(.25)
|
|
449
|
+
continue
|
|
443
450
|
if prompt_text_matches(_composer_text(selector), prompt):
|
|
444
451
|
return selector
|
|
445
452
|
|
|
@@ -567,5 +574,18 @@ while time.time() < deadline:
|
|
|
567
574
|
else:
|
|
568
575
|
raise RuntimeError("Temporary Chat activation was not observed")
|
|
569
576
|
|
|
570
|
-
|
|
577
|
+
try:
|
|
578
|
+
result = _submit_current_page(prompt, temporary=True, mode_label="Temporary Chat")
|
|
579
|
+
except RuntimeError as exc:
|
|
580
|
+
# A URL-prefilled Temporary Chat can retain the pre-hydration home input
|
|
581
|
+
# without ever mounting the active composer. No send occurred, so retry
|
|
582
|
+
# once on a clean Temporary Chat page without URL prompt prefill.
|
|
583
|
+
if "composer readiness was not observed ready" not in str(exc):
|
|
584
|
+
raise
|
|
585
|
+
goto_url(temporary_chat_entry_url())
|
|
586
|
+
wait_for_load()
|
|
587
|
+
_click_temporary_chat_toggle()
|
|
588
|
+
if not _temporary_chat_enabled():
|
|
589
|
+
raise RuntimeError("Temporary Chat mode lost during composer recovery") from exc
|
|
590
|
+
result = _submit_current_page(prompt, temporary=True, mode_label="Temporary Chat")
|
|
571
591
|
print(json.dumps(result, ensure_ascii=False), flush=True)
|
|
@@ -3,7 +3,13 @@ import os
|
|
|
3
3
|
import re
|
|
4
4
|
import time
|
|
5
5
|
from urllib.parse import urlsplit
|
|
6
|
-
from _readiness import
|
|
6
|
+
from _readiness import (
|
|
7
|
+
SubmissionVerificationError,
|
|
8
|
+
submission_failure_diagnostic,
|
|
9
|
+
submission_receipt,
|
|
10
|
+
prompt_text_matches,
|
|
11
|
+
wait_until_stable,
|
|
12
|
+
)
|
|
7
13
|
|
|
8
14
|
|
|
9
15
|
CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
|
|
@@ -260,6 +266,14 @@ def _submit(prompt, files, target_id):
|
|
|
260
266
|
phase="send readiness",
|
|
261
267
|
)
|
|
262
268
|
selector=state["selector"]
|
|
269
|
+
ui_before={
|
|
270
|
+
"composer_present": bool(_composer()),
|
|
271
|
+
"composer_text_length": len((_composer_text(selector) or "").strip()),
|
|
272
|
+
"send_present": bool(_send_state().get("present")),
|
|
273
|
+
"send_enabled": bool(_send_state().get("enabled")),
|
|
274
|
+
"generating": _is_generating(),
|
|
275
|
+
"user_turn_count": len(_user_turns()),
|
|
276
|
+
}
|
|
263
277
|
clicked=js("""(() => {
|
|
264
278
|
const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"]');
|
|
265
279
|
if (!b || b.disabled || b.getAttribute('aria-disabled') === 'true') return false;
|
|
@@ -272,7 +286,22 @@ def _submit(prompt, files, target_id):
|
|
|
272
286
|
receipt=submission_receipt(_user_turns(), before_users, prompt, _composer_text(selector))
|
|
273
287
|
if receipt: break
|
|
274
288
|
time.sleep(.25)
|
|
275
|
-
if not receipt:
|
|
289
|
+
if not receipt:
|
|
290
|
+
raise SubmissionVerificationError(
|
|
291
|
+
"ChatGPT submission verification did not observe an accepted submission",
|
|
292
|
+
submission_failure_diagnostic(
|
|
293
|
+
_user_turns(), before_users, prompt, _composer_text(selector),
|
|
294
|
+
ui_before,
|
|
295
|
+
{
|
|
296
|
+
"composer_present": bool(_composer()),
|
|
297
|
+
"composer_text_length": len((_composer_text(selector) or "").strip()),
|
|
298
|
+
"send_present": bool(_send_state().get("present")),
|
|
299
|
+
"send_enabled": bool(_send_state().get("enabled")),
|
|
300
|
+
"generating": _is_generating(),
|
|
301
|
+
"user_turn_count": len(_user_turns()),
|
|
302
|
+
},
|
|
303
|
+
),
|
|
304
|
+
)
|
|
276
305
|
return before_assistants, attachments, receipt
|
|
277
306
|
|
|
278
307
|
def _result(before_assistants, timeout):
|