agents-relay 1.0.3 → 1.0.5

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.
@@ -0,0 +1,132 @@
1
+ # ChatGPT browser-worker contract
2
+
3
+ This document is the stable boundary between Neo orchestration and a
4
+ `browser-harness` adapter. It is intentionally independent of ChatGPT's DOM,
5
+ URL layout, or internal network calls.
6
+
7
+ ## Requests
8
+
9
+ All requests contain an operation and no credentials:
10
+
11
+ ```json
12
+ {
13
+ "operation": "create",
14
+ "project": {"name": "neo", "id": "project-optional"},
15
+ "prompt": "Run the assigned task.",
16
+ "thinking_level": "high"
17
+ }
18
+ ```
19
+
20
+ `create` requires `project.name`, `prompt`, and `thinking_level`.
21
+ `resume` requires `thread_id` and `project`; its thinking level is optional and
22
+ defaults to the persisted request. `status`, `result`, and `delete` require
23
+ only `thread_id`.
24
+
25
+ Allowed requested thinking levels are `default`, `low`, `medium`, and `high`.
26
+ The adapter may expose a current UI label in an observation, but it must map it
27
+ to one of these values or `unknown` rather than guessing.
28
+
29
+ ## Durable thread state
30
+
31
+ The state file is the only persisted worker identity. It may look like:
32
+
33
+ ```json
34
+ {
35
+ "schema_version": 1,
36
+ "thread_id": "chatgpt-conversation-id",
37
+ "conversation_url": "https://chatgpt.com/c/chatgpt-conversation-id",
38
+ "project": {"id": "project-id", "name": "neo"},
39
+ "status": "awaiting_result",
40
+ "requested_thinking_level": "high",
41
+ "effective_thinking_level": "high",
42
+ "created_at": "2026-09-19T12:00:00Z",
43
+ "updated_at": "2026-09-19T12:01:00Z",
44
+ "last_error": null
45
+ }
46
+ ```
47
+
48
+ Required fields are `schema_version`, `thread_id`, `project.name`, `status`,
49
+ `requested_thinking_level`, and `effective_thinking_level`. `project.id` and
50
+ `conversation_url` are optional because the UI may not expose them at every
51
+ boundary, but the adapter must preserve them when observed.
52
+
53
+ The only valid statuses are:
54
+
55
+ | Status | Meaning |
56
+ | --- | --- |
57
+ | `created` | identity exists; no prompt has been sent yet |
58
+ | `running` | a prompt was sent and work is in progress |
59
+ | `awaiting_result` | the UI indicates a response may be read |
60
+ | `completed` | an assistant result was observed and normalized |
61
+ | `failed` | the operation failed; recovery may resume this identity |
62
+ | `blocked` | human/authentication/ambiguity decision is required |
63
+ | `deleted` | cleanup was verified; identity must not be reused |
64
+
65
+ Valid transitions are:
66
+
67
+ ```text
68
+ create -> created -> running -> awaiting_result -> completed
69
+ | |
70
+ +-> failed +-> running (follow-up)
71
+ any live state -> blocked
72
+ any live state -> deleted (only after verified cleanup)
73
+ failed/blocked -> running (only after an explicit recovery operation)
74
+ ```
75
+
76
+ `deleted` is terminal. A new conversation gets a new `thread_id`.
77
+
78
+ ## Results
79
+
80
+ ```json
81
+ {
82
+ "thread_id": "chatgpt-conversation-id",
83
+ "status": "completed",
84
+ "text": "The assistant's normalized final answer.",
85
+ "message_id": "observed-message-id",
86
+ "observed_at": "2026-09-19T12:02:00Z"
87
+ }
88
+ ```
89
+
90
+ `text` is required only for `completed`. Partial or streaming text is not a
91
+ completed result. A result with no observed assistant message is an error, not
92
+ success.
93
+
94
+ ## Browser adapter port
95
+
96
+ The adapter is tested through a fake port with these semantic calls:
97
+
98
+ ```text
99
+ select_project(project) -> observed_project
100
+ open_thread(thread_id) -> observed_thread
101
+ set_thinking_level(level) -> observation
102
+ send_prompt(prompt) -> observation
103
+ read_status() -> status_observation
104
+ read_result() -> result_observation
105
+ delete_thread() -> cleanup_observation
106
+ ```
107
+
108
+ `cleanup_observation` must contain the requested `thread_id` (or omit it only
109
+ when the browser has verified the thread is absent), `outcome` equal to
110
+ `deleted` or `not_found`, and `verified: true`. `not_found` is a successful
111
+ idempotent retry. Archive/undo controls may be reported as observational
112
+ metadata, but they do not turn a delete into a recoverable state: a deleted
113
+ tombstone remains terminal and must never be reused.
114
+
115
+ These names describe the boundary, not a required Python class or browser
116
+ selector implementation. Each call must return observed values or a typed
117
+ failure. The contract layer must remain usable with a fake port and must not
118
+ import or launch `browser-harness` itself.
119
+
120
+ ## Testable safety boundaries
121
+
122
+ - no request can omit the Project on `create` or `resume`;
123
+ - Project IDs are compared when both browser boundaries expose them; a
124
+ name-only observation remains valid because the UI may hide opaque IDs;
125
+ - no state can omit a non-empty `thread_id` or valid status;
126
+ - `resume` rejects an observed Project mismatch;
127
+ - requested and effective thinking levels are distinct fields;
128
+ - unknown effective level stays `unknown`;
129
+ - only an observed assistant message yields `completed`;
130
+ - delete is terminal and cannot be followed by resume;
131
+ - browser, login, and ambiguity failures preserve the thread identity and are
132
+ classified as `failed` or `blocked`.
@@ -0,0 +1,101 @@
1
+ # Neo/Leo orchestration integration
2
+
3
+ This skill is a worker capability, not an orchestrator or a browser session
4
+ manager. The `browser-worker` agent owns adaptive browser execution. Agents
5
+ Relay (or Leo's owning orchestrator) owns the task, correlation ID, retries,
6
+ events, and durable run location. The worker owns one ChatGPT thread state
7
+ record. `browser-harness` is the only layer that touches the authenticated
8
+ browser.
9
+
10
+ ## Responsibility split
11
+
12
+ | Layer | Responsibility | Must not do |
13
+ | --- | --- | --- |
14
+ | Agents Relay / Leo | Create the child task, pass the exact `job_id` and `task_id`, consume lifecycle events, and reconcile terminal state | Select DOM elements, infer a thread from a tab, or replace a deleted thread |
15
+ | Browser-worker agent | Load both skills, execute typed lifecycle intents, adapt to ordinary UI drift by re-observing, preserve `thread_id`/Project identity, and return verified observations | Bypass authentication, MFA, consent, ambiguity, or unverifiable results |
16
+ | This skill | Validate requests, preserve `thread_id` state, enforce Project and status transitions, and normalize observed results | Launch a second browser stack, call a ChatGPT API, or depend on MacBridge as a ChatGPT runtime |
17
+ | `browser-harness` adapter | Perform the observable browser actions and return semantic observations | Persist orchestration state, invent success from a process exit, or hide authentication/ambiguity failures |
18
+
19
+ MacBridge may be the transport used by an outer Leo deployment to start a
20
+ worker, but it is not part of this ChatGPT runtime path. A local worker can
21
+ invoke `browser-harness` directly. No MacBridge ChatGPT MCP tool, copied
22
+ cookie, or ChatGPT API is required.
23
+
24
+ ## Task handoff
25
+
26
+ The parent creates one child task with the same `job_id` and a task-specific
27
+ `task_id` (nested work also receives `parent_task_id`). The task input contains
28
+ an operation request and a run-local state path; it does not contain browser
29
+ credentials or cookies:
30
+
31
+ ```json
32
+ {
33
+ "job_id": "neo-chatgpt-browser-worker-20260919",
34
+ "task_id": "chatgpt-thread-1",
35
+ "operation": "create",
36
+ "state_path": "runs/2026-09-19-chatgpt-thread/state.json",
37
+ "request": {
38
+ "project": {"name": "neo"},
39
+ "prompt": "Run the assigned task.",
40
+ "thinking_level": "high"
41
+ }
42
+ }
43
+ ```
44
+
45
+ The exact literal IDs must be present in both the worker environment and its
46
+ prompt. Runtime state, prompts containing private data, logs, and browser
47
+ session details stay under the caller's ignored `runs/` directory.
48
+
49
+ ## Lifecycle invocation
50
+
51
+ The orchestrator launches the `browser-worker` agent with one serial typed
52
+ operation at a time and persists the returned state before acknowledging
53
+ success. It must not directly invoke `create_bh.py` or `operate_bh.py`; those
54
+ are agent implementation helpers. The durable identity is always the
55
+ `thread_id` in `state.json`, never a URL, title, or tab index.
56
+
57
+ | Intent | Request | Browser-port calls | Success evidence |
58
+ | --- | --- | --- | --- |
59
+ | Start | `create` with `project`, `prompt`, `thinking_level` | `select_project` → optional `set_thinking_level` → `send_prompt` | observed non-empty `thread_id` and Project |
60
+ | Reattach | `resume` with `thread_id`, `project` | `open_thread` and Project verification | opened matching thread and Project |
61
+ | Fetch | `result` with the durable state | `read_result` | normalized assistant message with `message_id` |
62
+ | Clean up | `delete` with the durable state | exact-thread delete plus confirmation | verified `deleted` or idempotent `not_found` |
63
+
64
+ `status` is the optional polling operation between `resume` and `result`.
65
+ `continue` is a follow-up prompt operation and is not a substitute for
66
+ `resume`. A retry resumes the existing state; it never calls `create` for a
67
+ missing or failed browser action. A `deleted` tombstone is terminal.
68
+
69
+ The pure entry points are `create_thread`, `resume_thread`, `result_thread`,
70
+ and `delete_thread` in `scripts/create.py` and `scripts/operations.py`. The
71
+ `*_bh.py` scripts are thin helpers the agent may call or replace while running
72
+ one operation through `browser-harness`; they are not an alternate state store,
73
+ orchestrator, or caller-facing runtime.
74
+
75
+ ## Events and terminal handling
76
+
77
+ The owning orchestrator emits `task.started`, meaningful milestones, and one
78
+ terminal task event using the shared events-bus contract. A worker should
79
+ report routine success as `task.completed` with `visibility: orchestrator`;
80
+ the parent then reconciles the state file and emits the user-visible result.
81
+ Failures involving login, MFA, consent, ambiguous Project selection, or an
82
+ unverified result are `task.failed` or `task.blocked`, not successful relay
83
+ completion. Event payloads contain IDs, statuses, and evidence summaries—not
84
+ credentials, cookies, or full private prompts.
85
+
86
+ ## Minimal relay loop
87
+
88
+ ```text
89
+ watch(job_id)
90
+ launch child with job_id/task_id and operation=create
91
+ persist returned thread state
92
+ resume(thread_id, project) when reattaching
93
+ repeat status(thread_id) until the UI permits a result
94
+ result(thread_id) and validate the normalized assistant message
95
+ delete(thread_id) in the explicit cleanup phase
96
+ consume events until the parent task reaches one terminal state
97
+ ```
98
+
99
+ The browser adapter may fail after a thread has been created. In that case,
100
+ preserve the last known `thread_id` and state, classify the transition as
101
+ `failed` or `blocked`, and let the orchestrator decide when to retry `resume`.
@@ -0,0 +1,158 @@
1
+ import json
2
+ import re
3
+ import time
4
+
5
+ from browser_harness import *
6
+
7
+
8
+ CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
9
+
10
+
11
+ def _ax_nodes():
12
+ return cdp("Accessibility.getFullAXTree").get("nodes", [])
13
+
14
+
15
+ def _click_exact(name, roles=()):
16
+ matches = [node for node in _ax_nodes() if node.get("name", {}).get("value") == name and (not roles or node.get("role", {}).get("value") in roles)]
17
+ if len(matches) != 1:
18
+ raise RuntimeError(f"expected one accessible {name!r}, found {len(matches)}")
19
+ backend_id = matches[0].get("backendDOMNodeId")
20
+ box = cdp("DOM.getBoxModel", backendNodeId=backend_id).get("model", {}).get("content")
21
+ if not box:
22
+ raise RuntimeError(f"accessible {name!r} has no visible box")
23
+ click_at_xy(sum(box[0::2]) / 4, sum(box[1::2]) / 4)
24
+ return {"name": name, "role": matches[0].get("role", {}).get("value")}
25
+
26
+
27
+ def _click_if_present(name, roles=()):
28
+ matches = [node for node in _ax_nodes() if node.get("name", {}).get("value") == name and (not roles or node.get("role", {}).get("value") in roles)]
29
+ if len(matches) > 1:
30
+ raise RuntimeError(f"ambiguous accessible {name!r}: found {len(matches)}")
31
+ if matches:
32
+ return _click_exact(name, roles)
33
+ return None
34
+
35
+
36
+ def _find_composer():
37
+ selector = js("""(() => {
38
+ const candidates = [...document.querySelectorAll('textarea,[contenteditable="true"]')];
39
+ const node = candidates.find(e => {
40
+ const r = e.getBoundingClientRect();
41
+ return r.width > 0 && r.height > 0 && !e.disabled;
42
+ });
43
+ if (!node) return null;
44
+ if (node.id) return '#' + CSS.escape(node.id);
45
+ node.setAttribute('data-agents-relay-composer', 'true');
46
+ return '[data-agents-relay-composer="true"]';
47
+ })()""")
48
+ if not selector:
49
+ raise RuntimeError("ChatGPT composer was not observed")
50
+ return selector
51
+
52
+
53
+ def _thread_id(existing_hrefs, timeout=20.0):
54
+ deadline = time.time() + timeout
55
+ existing_ids = set()
56
+ for href in existing_hrefs or []:
57
+ match = re.search(r"/c/([^/?#]+)", href or "")
58
+ if match:
59
+ existing_ids.add(match.group(1))
60
+ while time.time() < deadline:
61
+ match = re.search(r"/c/([^/?#]+)", page_info().get("url", ""))
62
+ if match:
63
+ return match.group(1)
64
+ links = js("""(() => [...document.querySelectorAll('a[href*="/g/"][href*="/c/"]')].map(a => ({href:a.href,text:(a.textContent||'').trim()})))()""") or []
65
+ prompt_hint = (CFG.get("prompt") or "").strip()[:60]
66
+ matching = []
67
+ for link in links:
68
+ href = link.get("href") or ""
69
+ link_match = re.search(r"/c/([^/?#]+)", href)
70
+ if link_match and prompt_hint and prompt_hint in (link.get("text") or ""):
71
+ matching.append(link_match.group(1))
72
+ unique_matching = list(dict.fromkeys(matching))
73
+ if len(unique_matching) == 1:
74
+ return unique_matching[0]
75
+ if len(unique_matching) > 1:
76
+ new_matching = [value for value in unique_matching if value not in existing_ids]
77
+ if len(new_matching) == 1:
78
+ return new_matching[0]
79
+ raise RuntimeError("multiple prompt-matching durable thread ids were observed after submission")
80
+ time.sleep(0.25)
81
+ raise RuntimeError("ChatGPT did not expose a new durable thread id before timeout")
82
+
83
+
84
+ ensure_real_tab()
85
+ if "chatgpt.com" not in page_info().get("url", ""):
86
+ new_tab("https://chatgpt.com/")
87
+ wait_for_load()
88
+ project = CFG["project"]
89
+ selected = {"name": project["name"], "action": "default_chat"}
90
+ if project["name"].casefold() not in ("unspecified", "default"):
91
+ # Project rows expose a generic "Open project home" action, so bind that
92
+ # action to the visible row whose marquee text exactly matches the requested
93
+ # Project. This avoids relying on the project label itself being clickable.
94
+ opened_project = js("""(() => {
95
+ const wanted = %s;
96
+ const labels = [...document.querySelectorAll('[data-marquee-text]')].filter(e => (e.textContent || '').trim() === wanted);
97
+ for (const label of labels) {
98
+ const button = label.closest('li')?.querySelector('button[aria-label="Open project home"]');
99
+ if (!button) continue;
100
+ const r = button.getBoundingClientRect();
101
+ if (r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < innerHeight) { button.click(); return true; }
102
+ }
103
+ return false;
104
+ })()""" % json.dumps(project["name"]))
105
+ if not opened_project:
106
+ raise RuntimeError(f"requested Project {project['name']!r} has no visible project-home action")
107
+ deadline = time.time() + 8
108
+ visible = ""
109
+ while time.time() < deadline:
110
+ info = page_info()
111
+ visible = js("document.body.innerText") or ""
112
+ if project["name"].casefold() in visible.casefold() and project["name"].casefold() in info.get("title", "").casefold() and "/project" in info.get("url", ""):
113
+ break
114
+ time.sleep(0.25)
115
+ else:
116
+ raise RuntimeError("requested Project home was not observed after navigation")
117
+ selected = {"name": project["name"], "action": "Open project home"}
118
+
119
+ thinking = {"requested": CFG["thinking_level"], "effective_thinking_level": "unknown", "changed": False}
120
+ if CFG["thinking_level"] != "default":
121
+ label = {"low": "Low", "medium": "Medium", "high": "High"}[CFG["thinking_level"]]
122
+ level_buttons = []
123
+ deadline = time.time() + 5
124
+ while time.time() < deadline and len(level_buttons) != 1:
125
+ level_buttons = [node for node in _ax_nodes() if node.get("role", {}).get("value") == "button" and node.get("name", {}).get("value") in ("Low", "Medium", "High")]
126
+ if len(level_buttons) != 1:
127
+ time.sleep(0.25)
128
+ if len(level_buttons) == 1:
129
+ current = level_buttons[0].get("name", {}).get("value")
130
+ if current != label:
131
+ _click_exact(current, roles=("button",))
132
+ time.sleep(0.25)
133
+ try:
134
+ _click_exact(label, roles=("button", "menuitem", "option"))
135
+ thinking = {"requested": CFG["thinking_level"], "effective_thinking_level": CFG["thinking_level"], "changed": True}
136
+ except RuntimeError:
137
+ thinking = {"requested": CFG["thinking_level"], "effective_thinking_level": "unknown", "changed": False}
138
+ else:
139
+ thinking = {"requested": CFG["thinking_level"], "effective_thinking_level": CFG["thinking_level"], "changed": False}
140
+
141
+ existing_hrefs = js("""(() => [...document.querySelectorAll('a[href*="/g/"][href*="/c/"]')].map(a => a.href))()""") or []
142
+ composer = _find_composer()
143
+ fill_input(composer, CFG["prompt"], clear_first=True)
144
+ deadline = time.time() + 5
145
+ sent = False
146
+ while time.time() < deadline:
147
+ sent = bool(js("""(() => { const button = document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"]'); if (!button) return false; const r=button.getBoundingClientRect(); if (!(r.width > 0 && r.height > 0)) return false; button.click(); return true; })()"""))
148
+ if sent: break
149
+ time.sleep(0.1)
150
+ if not sent: raise RuntimeError("ChatGPT Send prompt button was not observed after filling composer")
151
+ thread_id = _thread_id(existing_hrefs)
152
+ print(json.dumps({
153
+ "thread_id": thread_id,
154
+ "conversation_url": page_info().get("url"),
155
+ "selected_project": selected,
156
+ "thinking": thinking,
157
+ "prompt_sent": True,
158
+ }, ensure_ascii=False))
@@ -0,0 +1,198 @@
1
+ import json
2
+ import time
3
+
4
+ from browser_harness import *
5
+
6
+
7
+ CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
8
+ THREAD_ID = CFG["thread_id"]
9
+
10
+
11
+ def _ax_nodes():
12
+ return cdp("Accessibility.getFullAXTree").get("nodes", [])
13
+
14
+
15
+ def _composer():
16
+ selector = js("""(() => {
17
+ const nodes = [...document.querySelectorAll('textarea,[contenteditable="true"]')];
18
+ const node = nodes.find(e => { const r = e.getBoundingClientRect(); return r.width && r.height && !e.disabled; });
19
+ if (!node) return null;
20
+ if (node.id) return '#' + CSS.escape(node.id);
21
+ if (node.getAttribute('aria-label')) return `${node.tagName.toLowerCase()}[aria-label=${JSON.stringify(node.getAttribute('aria-label'))}]`;
22
+ return node.tagName === 'TEXTAREA' ? 'textarea' : 'div[contenteditable="true"]';
23
+ })()""")
24
+ if not selector:
25
+ raise RuntimeError("ChatGPT composer was not observed")
26
+ return selector
27
+
28
+
29
+ def _assistant_messages():
30
+ return js("""(() => [...document.querySelectorAll('[data-message-author-role="assistant"]')]
31
+ .map(e => ({text: e.innerText.trim(), id: e.getAttribute('data-message-id') || ''}))
32
+ .filter(e => e.text))()""") or []
33
+
34
+
35
+ def _is_generating():
36
+ return bool(js('!!document.querySelector(\'button[aria-label*="Stop" i],button[data-testid*="stop" i]\')'))
37
+
38
+
39
+ def _click_accessible(name, prefer_rightmost=False):
40
+ visible = []
41
+ info = page_info()
42
+ for node in _ax_nodes():
43
+ if node.get("name", {}).get("value") != name:
44
+ continue
45
+ backend_id = node.get("backendDOMNodeId")
46
+ if not backend_id:
47
+ continue
48
+ try:
49
+ box = cdp("DOM.getBoxModel", backendNodeId=backend_id).get("model", {}).get("content")
50
+ except Exception:
51
+ continue
52
+ if not box:
53
+ continue
54
+ x, y = sum(box[0::2]) / 4, sum(box[1::2]) / 4
55
+ if 0 <= x <= info.get("w", 0) and 0 <= y <= info.get("h", 0):
56
+ visible.append((x, y))
57
+ if not visible:
58
+ raise RuntimeError(f"expected a visible accessible {name!r}, found 0")
59
+ if len(visible) > 1 and not prefer_rightmost:
60
+ raise RuntimeError(f"expected one visible accessible {name!r}, found {len(visible)}")
61
+ click_at_xy(*(max(visible, key=lambda point: point[0]) if prefer_rightmost else visible[0]))
62
+
63
+
64
+ def _stable_assistant_result(delay=0.75):
65
+ if _is_generating():
66
+ return None
67
+ first = _assistant_messages()
68
+ if not first:
69
+ return None
70
+ candidate = first[-1]
71
+ time.sleep(delay)
72
+ if _is_generating():
73
+ return None
74
+ second = _assistant_messages()
75
+ if not second:
76
+ return None
77
+ latest = second[-1]
78
+ if latest.get("id") != candidate.get("id") or latest.get("text") != candidate.get("text"):
79
+ return None
80
+ return latest
81
+
82
+
83
+
84
+ def _wait_stable_assistant_result(timeout=15.0):
85
+ deadline = time.time() + timeout
86
+ while time.time() < deadline:
87
+ message = _stable_assistant_result(delay=0.5)
88
+ if message:
89
+ return message
90
+ time.sleep(0.25)
91
+ return None
92
+
93
+
94
+ def _wait_for_requested_thread(timeout=12.0):
95
+ deadline = time.time() + timeout
96
+ last_url = ""
97
+ while time.time() < deadline:
98
+ info = page_info()
99
+ last_url = info.get("url", "")
100
+ body = js("document.body.innerText") or ""
101
+ title = info.get("title", "")
102
+ if f"/c/{THREAD_ID}" in last_url and (CFG["project"].casefold() in body.casefold() or CFG["project"].casefold() in title.casefold()):
103
+ return body
104
+ time.sleep(0.25)
105
+ raise RuntimeError(f"requested Project/thread was not observed after navigation: {last_url}")
106
+
107
+ def _cleanup():
108
+ """Delete exactly the requested durable thread and verify disappearance."""
109
+ url = page_info().get("url", "")
110
+ if f"/c/{THREAD_ID}" not in url:
111
+ raise RuntimeError("browser is not on the requested durable thread")
112
+ body = js("document.body.innerText") or ""
113
+ if CFG["project"].casefold() not in body.casefold() and CFG["project"].casefold() not in page_info().get("title", "").casefold():
114
+ lower_body = body.lower()
115
+ missing_markers = ("conversation not found", "conversation doesn't exist", "conversation does not exist", "page not found")
116
+ if any(marker in lower_body for marker in missing_markers):
117
+ return {"thread_id": THREAD_ID, "outcome": "not_found", "verified": True, "url_after": url, "reason": "thread was not observable"}
118
+ raise RuntimeError("requested thread could not be verified; refusing cleanup")
119
+
120
+ opened = js("""(() => {
121
+ const buttons = [...document.querySelectorAll('button[aria-label="More"]')].filter(button => {
122
+ const r = button.getBoundingClientRect();
123
+ return r.width > 0 && r.height > 0 && r.top >= 0 && r.bottom <= innerHeight;
124
+ });
125
+ if (buttons.length !== 1) return false;
126
+ buttons[0].click();
127
+ return true;
128
+ })()""")
129
+ if not opened:
130
+ raise RuntimeError("thread actions menu was not uniquely observed")
131
+ time.sleep(0.2)
132
+ selected = js("""(() => {
133
+ const items = [...document.querySelectorAll('[role="menuitem"]')].filter(item => (item.textContent || '').trim() === 'Delete');
134
+ if (items.length !== 1) return false;
135
+ items[0].click();
136
+ return true;
137
+ })()""")
138
+ if not selected:
139
+ raise RuntimeError("ChatGPT delete action was not uniquely observed")
140
+
141
+ deadline = time.time() + 5
142
+ confirmed = False
143
+ while time.time() < deadline:
144
+ confirmed = bool(js("""(() => {
145
+ const dialogs = [...document.querySelectorAll('[role="dialog"]')].filter(dialog => (dialog.textContent || '').includes('Delete chat?'));
146
+ if (dialogs.length !== 1) return false;
147
+ const buttons = [...dialogs[0].querySelectorAll('button')].filter(button => (button.textContent || '').trim() === 'Delete');
148
+ if (buttons.length !== 1) return false;
149
+ buttons[0].click();
150
+ return true;
151
+ })()"""))
152
+ if confirmed:
153
+ break
154
+ time.sleep(0.2)
155
+ if not confirmed:
156
+ raise RuntimeError("ChatGPT delete confirmation was not observed")
157
+
158
+ deadline = time.time() + 8
159
+ while time.time() < deadline:
160
+ after_url = page_info().get("url", "")
161
+ after_body = js("document.body.innerText") or ""
162
+ if f"/c/{THREAD_ID}" not in after_url or "Undo" in after_body:
163
+ return {"thread_id": THREAD_ID, "outcome": "deleted", "verified": True, "url_after": after_url, "undo_visible": "Undo" in after_body, "archive_visible": "Archive" in after_body}
164
+ time.sleep(0.25)
165
+ raise RuntimeError("requested thread is still observable after delete")
166
+
167
+
168
+ ensure_real_tab()
169
+ target = f"https://chatgpt.com/c/{THREAD_ID}"
170
+ if f"/c/{THREAD_ID}" not in page_info().get("url", ""):
171
+ new_tab(target)
172
+ wait_for_load()
173
+ body = _wait_for_requested_thread()
174
+ operation = CFG["operation"]
175
+ if operation == "delete":
176
+ print(json.dumps(_cleanup(), ensure_ascii=False))
177
+ raise SystemExit
178
+
179
+ base = {"thread_id": THREAD_ID, "conversation_url": page_info().get("url"), "project": {"name": CFG["project"]}}
180
+ if operation == "continue":
181
+ fill_input(_composer(), CFG["prompt"], clear_first=True)
182
+ time.sleep(0.1)
183
+ _click_accessible("Send prompt")
184
+ print(json.dumps({**base, "prompt_sent": True}, ensure_ascii=False))
185
+ elif operation == "status":
186
+ messages = _assistant_messages()
187
+ stable = _stable_assistant_result()
188
+ status = "completed" if stable else ("running" if _is_generating() or messages else "awaiting_result")
189
+ print(json.dumps({**base, "status": status, "assistant_message_count": len(messages)}, ensure_ascii=False))
190
+ elif operation == "result":
191
+ message = _wait_stable_assistant_result()
192
+ if not message:
193
+ raise RuntimeError("ChatGPT assistant result was not observed stable and final before timeout")
194
+ if not message["id"]:
195
+ raise RuntimeError("assistant result had no observed message id")
196
+ print(json.dumps({**base, "status": "completed", "text": message["text"], "message_id": message["id"]}, ensure_ascii=False))
197
+ else:
198
+ print(json.dumps({**base, "status": "completed" if _assistant_messages() else "awaiting_result"}, ensure_ascii=False))