browser-control-and-automation-cli 0.1.1.dev3__py3-none-any.whl

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 (104) hide show
  1. browser_cli/__init__.py +7 -0
  2. browser_cli/actions/__init__.py +5 -0
  3. browser_cli/actions/cli_specs.py +868 -0
  4. browser_cli/agent_scope/__init__.py +14 -0
  5. browser_cli/automation/__init__.py +11 -0
  6. browser_cli/automation/api/__init__.py +5 -0
  7. browser_cli/automation/api/server.py +321 -0
  8. browser_cli/automation/hooks.py +42 -0
  9. browser_cli/automation/loader.py +134 -0
  10. browser_cli/automation/models.py +127 -0
  11. browser_cli/automation/persistence/__init__.py +5 -0
  12. browser_cli/automation/persistence/store.py +682 -0
  13. browser_cli/automation/projections.py +279 -0
  14. browser_cli/automation/publisher.py +153 -0
  15. browser_cli/automation/scheduler/__init__.py +5 -0
  16. browser_cli/automation/scheduler/schedule.py +113 -0
  17. browser_cli/automation/service/__init__.py +8 -0
  18. browser_cli/automation/service/__main__.py +64 -0
  19. browser_cli/automation/service/client.py +232 -0
  20. browser_cli/automation/service/runtime.py +388 -0
  21. browser_cli/automation/toml.py +35 -0
  22. browser_cli/automation/web/__init__.py +5 -0
  23. browser_cli/automation/web/assets.py +377 -0
  24. browser_cli/browser/__init__.py +1 -0
  25. browser_cli/browser/models.py +44 -0
  26. browser_cli/browser/network_capture.py +392 -0
  27. browser_cli/browser/service.py +1519 -0
  28. browser_cli/browser/session.py +158 -0
  29. browser_cli/browser/snapshot.py +35 -0
  30. browser_cli/browser/stealth.py +450 -0
  31. browser_cli/cli/__init__.py +1 -0
  32. browser_cli/cli/error_hints.py +26 -0
  33. browser_cli/cli/main.py +298 -0
  34. browser_cli/commands/__init__.py +1 -0
  35. browser_cli/commands/action.py +25 -0
  36. browser_cli/commands/automation.py +304 -0
  37. browser_cli/commands/doctor.py +336 -0
  38. browser_cli/commands/install_skills.py +138 -0
  39. browser_cli/commands/paths.py +31 -0
  40. browser_cli/commands/read.py +33 -0
  41. browser_cli/commands/reload.py +51 -0
  42. browser_cli/commands/status.py +444 -0
  43. browser_cli/commands/task.py +71 -0
  44. browser_cli/constants.py +97 -0
  45. browser_cli/daemon/__init__.py +1 -0
  46. browser_cli/daemon/__main__.py +34 -0
  47. browser_cli/daemon/app.py +941 -0
  48. browser_cli/daemon/browser_service.py +998 -0
  49. browser_cli/daemon/client.py +280 -0
  50. browser_cli/daemon/models.py +63 -0
  51. browser_cli/daemon/runtime_presentation.py +167 -0
  52. browser_cli/daemon/server.py +103 -0
  53. browser_cli/daemon/state.py +15 -0
  54. browser_cli/daemon/transport.py +77 -0
  55. browser_cli/drivers/__init__.py +6 -0
  56. browser_cli/drivers/_extension/__init__.py +1 -0
  57. browser_cli/drivers/_extension/artifact_actions.py +154 -0
  58. browser_cli/drivers/_extension/dialog_actions.py +35 -0
  59. browser_cli/drivers/_extension/input_actions.py +63 -0
  60. browser_cli/drivers/_extension/locator_actions.py +100 -0
  61. browser_cli/drivers/_extension/observe_actions.py +285 -0
  62. browser_cli/drivers/_extension/page_actions.py +210 -0
  63. browser_cli/drivers/_extension/state_actions.py +134 -0
  64. browser_cli/drivers/base.py +391 -0
  65. browser_cli/drivers/extension_driver.py +33 -0
  66. browser_cli/drivers/models.py +21 -0
  67. browser_cli/drivers/playwright_driver.py +499 -0
  68. browser_cli/error_codes.py +27 -0
  69. browser_cli/errors.py +146 -0
  70. browser_cli/exit_codes.py +9 -0
  71. browser_cli/extension/__init__.py +32 -0
  72. browser_cli/extension/protocol.py +222 -0
  73. browser_cli/extension/session.py +393 -0
  74. browser_cli/network.py +147 -0
  75. browser_cli/outputs/__init__.py +1 -0
  76. browser_cli/outputs/json.py +10 -0
  77. browser_cli/outputs/render.py +7 -0
  78. browser_cli/packaged_skills/__init__.py +1 -0
  79. browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
  80. browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
  81. browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
  82. browser_cli/profiles/__init__.py +1 -0
  83. browser_cli/profiles/discovery.py +185 -0
  84. browser_cli/refs/__init__.py +17 -0
  85. browser_cli/refs/generator.py +386 -0
  86. browser_cli/refs/models.py +127 -0
  87. browser_cli/refs/registry.py +51 -0
  88. browser_cli/refs/resolver.py +214 -0
  89. browser_cli/tabs/__init__.py +5 -0
  90. browser_cli/tabs/registry.py +283 -0
  91. browser_cli/task_runtime/__init__.py +31 -0
  92. browser_cli/task_runtime/client.py +107 -0
  93. browser_cli/task_runtime/entrypoint.py +110 -0
  94. browser_cli/task_runtime/errors.py +17 -0
  95. browser_cli/task_runtime/flow.py +171 -0
  96. browser_cli/task_runtime/models.py +97 -0
  97. browser_cli/task_runtime/read.py +71 -0
  98. browser_cli/task_runtime/templates.py +58 -0
  99. browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
  100. browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
  101. browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
  102. browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
  103. browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
  104. browser_control_and_automation_cli-0.1.1.dev3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,214 @@
1
+ """Semantic ref resolver adapted from bridgic-browser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from browser_cli.refs.models import LocatorSpec, RefData
9
+
10
+
11
+ class SemanticRefResolver:
12
+ INTERACTIVE_ROLES = {
13
+ "application",
14
+ "alertdialog",
15
+ "button",
16
+ "checkbox",
17
+ "combobox",
18
+ "dialog",
19
+ "grid",
20
+ "gridcell",
21
+ "link",
22
+ "listbox",
23
+ "menu",
24
+ "menubar",
25
+ "menuitem",
26
+ "menuitemcheckbox",
27
+ "menuitemradio",
28
+ "option",
29
+ "progressbar",
30
+ "radio",
31
+ "radiogroup",
32
+ "scrollbar",
33
+ "search",
34
+ "searchbox",
35
+ "slider",
36
+ "spinbutton",
37
+ "switch",
38
+ "tab",
39
+ "tablist",
40
+ "tabpanel",
41
+ "textbox",
42
+ "tree",
43
+ "treegrid",
44
+ "treeitem",
45
+ }
46
+ ROLE_TEXT_MATCH_ROLES = {
47
+ "cell",
48
+ "columnheader",
49
+ "gridcell",
50
+ "listitem",
51
+ "row",
52
+ "rowheader",
53
+ }
54
+ STRUCTURAL_NOISE_ROLES = {"generic", "group", "none", "presentation"}
55
+ TEXT_LEAF_ROLES = {"text"}
56
+ STRUCTURAL_NOISE_CSS = {
57
+ "generic": 'div:not([role]), legend, [role="generic"]',
58
+ "group": 'fieldset, details, optgroup, [role="group"]',
59
+ "none": '[role="none"]',
60
+ "presentation": '[role="presentation"]',
61
+ }
62
+ STRUCTURAL_NOISE_CSS_NAMED = {
63
+ **STRUCTURAL_NOISE_CSS,
64
+ "generic": 'div:not([role]), span:not([role]), legend, [role="generic"]',
65
+ }
66
+
67
+ @staticmethod
68
+ def parse_ref(arg: str) -> str | None:
69
+ raw = arg.strip()
70
+ if raw.startswith("@"):
71
+ raw = raw[1:]
72
+ elif raw.startswith("ref="):
73
+ raw = raw[4:]
74
+ if re.fullmatch(r"[0-9a-fA-F]{8}", raw):
75
+ return raw.lower()
76
+ return None
77
+
78
+ @staticmethod
79
+ def _text_pattern(value: str, *, exact: bool) -> re.Pattern[str]:
80
+ parts = [re.escape(part) for part in re.split(r"\s+", value.strip()) if part]
81
+ joined = r"\s+".join(parts) if parts else ""
82
+ if exact:
83
+ return re.compile(rf"^\s*{joined}\s*$")
84
+ return re.compile(joined)
85
+
86
+ def get_locator(
87
+ self,
88
+ page: Any,
89
+ ref_arg: str,
90
+ refs: dict[str, RefData],
91
+ ) -> Any | None:
92
+ locator_spec = self.build_locator_spec(ref_arg, refs)
93
+ if locator_spec is None:
94
+ return None
95
+ return self.get_locator_from_spec(page, locator_spec)
96
+
97
+ def get_locator_from_spec(
98
+ self,
99
+ page: Any,
100
+ locator_spec: LocatorSpec,
101
+ ) -> Any | None:
102
+ if not locator_spec.role:
103
+ return None
104
+
105
+ normalized_name = (
106
+ locator_spec.name.strip() if locator_spec.name and locator_spec.name.strip() else None
107
+ )
108
+ normalized_text = (
109
+ locator_spec.text_content.strip()
110
+ if locator_spec.text_content and locator_spec.text_content.strip()
111
+ else None
112
+ )
113
+ match_text = locator_spec.match_text
114
+ child_text = locator_spec.child_text
115
+
116
+ scope: Any = page
117
+ if locator_spec.frame_path:
118
+ for local_nth in locator_spec.frame_path:
119
+ scope = scope.frame_locator("iframe").nth(local_nth)
120
+
121
+ skip_nth = False
122
+
123
+ if (
124
+ normalized_name
125
+ and locator_spec.role not in self.ROLE_TEXT_MATCH_ROLES
126
+ and locator_spec.role not in self.STRUCTURAL_NOISE_ROLES
127
+ and locator_spec.role not in self.TEXT_LEAF_ROLES
128
+ ):
129
+ locator = scope.get_by_role(locator_spec.role, name=normalized_name, exact=True)
130
+ elif locator_spec.role in self.ROLE_TEXT_MATCH_ROLES and match_text:
131
+ if locator_spec.role == "row":
132
+ locator = scope.get_by_role("row").filter(
133
+ has_text=self._text_pattern(match_text, exact=False)
134
+ )
135
+ else:
136
+ locator = scope.get_by_role(locator_spec.role).filter(
137
+ has_text=self._text_pattern(match_text, exact=True)
138
+ )
139
+ elif locator_spec.role in self.TEXT_LEAF_ROLES and match_text:
140
+ locator = scope.get_by_text(match_text, exact=True)
141
+ skip_nth = True
142
+ elif locator_spec.role in self.STRUCTURAL_NOISE_ROLES and match_text:
143
+ css = self.STRUCTURAL_NOISE_CSS.get(locator_spec.role)
144
+ if css:
145
+ locator = scope.locator(css).filter(
146
+ has_text=self._text_pattern(match_text, exact=True)
147
+ )
148
+ else:
149
+ locator = scope.get_by_text(match_text, exact=True)
150
+ skip_nth = True
151
+ elif locator_spec.role in self.STRUCTURAL_NOISE_ROLES:
152
+ if child_text:
153
+ css = self.STRUCTURAL_NOISE_CSS.get(locator_spec.role)
154
+ if css:
155
+ locator = scope.locator(css).filter(
156
+ has_text=self._text_pattern(child_text, exact=True)
157
+ )
158
+ else:
159
+ locator = scope.get_by_text(child_text, exact=True)
160
+ skip_nth = True
161
+ else:
162
+ locator = scope.get_by_role(locator_spec.role)
163
+ elif normalized_text:
164
+ locator = scope.get_by_text(normalized_text, exact=True)
165
+ skip_nth = True
166
+ else:
167
+ locator = scope.get_by_role(locator_spec.role, name=re.compile(r"^$"))
168
+
169
+ if not skip_nth and locator_spec.nth is not None:
170
+ locator = locator.nth(locator_spec.nth)
171
+ return locator
172
+
173
+ def build_locator_spec(self, ref_arg: str, refs: dict[str, RefData]) -> LocatorSpec | None:
174
+ ref = self.parse_ref(ref_arg)
175
+ if not ref:
176
+ return None
177
+ ref_data = refs.get(ref)
178
+ if ref_data is None:
179
+ return None
180
+ normalized_name = ref_data.name.strip() if ref_data.name and ref_data.name.strip() else None
181
+ normalized_text = (
182
+ ref_data.text_content.strip()
183
+ if ref_data.text_content and ref_data.text_content.strip()
184
+ else None
185
+ )
186
+ match_text = normalized_name or normalized_text
187
+ child_text = None
188
+ if ref_data.role in self.STRUCTURAL_NOISE_ROLES:
189
+ child_text = next(
190
+ (
191
+ (child.name or child.text_content or "").strip()
192
+ for child in refs.values()
193
+ if child.parent_ref == ref
194
+ and (
195
+ child.role in self.TEXT_LEAF_ROLES
196
+ or child.role in self.STRUCTURAL_NOISE_ROLES
197
+ )
198
+ and (child.name or child.text_content)
199
+ ),
200
+ None,
201
+ )
202
+ return LocatorSpec(
203
+ ref=ref,
204
+ role=ref_data.role,
205
+ name=normalized_name,
206
+ text_content=normalized_text,
207
+ match_text=match_text,
208
+ child_text=child_text,
209
+ nth=ref_data.nth,
210
+ tag=ref_data.tag,
211
+ interactive=ref_data.interactive,
212
+ frame_path=ref_data.frame_path,
213
+ selector_recipe=ref_data.selector_recipe,
214
+ )
@@ -0,0 +1,5 @@
1
+ """Tab registry primitives."""
2
+
3
+ from .registry import BusyState, TabRecord, TabRegistry
4
+
5
+ __all__ = ["BusyState", "TabRecord", "TabRegistry"]
@@ -0,0 +1,283 @@
1
+ """Agent-scoped tab ownership and conflict tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass, field
10
+
11
+ from browser_cli.errors import BusyTabError, NoActiveTabError, TabNotFoundError
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class BusyState:
16
+ request_id: str
17
+ command: str
18
+ agent_id: str
19
+ started_at: float
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class TabRecord:
24
+ page_id: str
25
+ owner_agent_id: str
26
+ created_at: float
27
+ last_used_at: float
28
+ url: str = ""
29
+ title: str = ""
30
+ busy: BusyState | None = None
31
+ last_snapshot_refs: set[str] = field(default_factory=set)
32
+ last_snapshot_id: str | None = None
33
+ last_snapshot_ref_count: int = 0
34
+ last_snapshot_url: str = ""
35
+ last_snapshot_at: float | None = None
36
+
37
+
38
+ class TabRegistry:
39
+ def __init__(self) -> None:
40
+ self._tabs: dict[str, TabRecord] = {}
41
+ self._active_tab_by_agent: dict[str, str] = {}
42
+ self._lock = asyncio.Lock()
43
+
44
+ async def add_tab(
45
+ self,
46
+ *,
47
+ page_id: str,
48
+ owner_agent_id: str,
49
+ url: str = "",
50
+ title: str = "",
51
+ ) -> TabRecord:
52
+ now = time.time()
53
+ async with self._lock:
54
+ record = TabRecord(
55
+ page_id=page_id,
56
+ owner_agent_id=owner_agent_id,
57
+ created_at=now,
58
+ last_used_at=now,
59
+ url=url,
60
+ title=title,
61
+ )
62
+ self._tabs[page_id] = record
63
+ self._active_tab_by_agent[owner_agent_id] = page_id
64
+ return record
65
+
66
+ async def update_tab(
67
+ self,
68
+ page_id: str,
69
+ *,
70
+ url: str | None = None,
71
+ title: str | None = None,
72
+ last_snapshot_refs: set[str] | None = None,
73
+ last_snapshot_id: str | None = None,
74
+ last_snapshot_ref_count: int | None = None,
75
+ last_snapshot_url: str | None = None,
76
+ last_snapshot_at: float | None = None,
77
+ ) -> TabRecord:
78
+ async with self._lock:
79
+ record = self._tabs.get(page_id)
80
+ if record is None:
81
+ raise TabNotFoundError()
82
+ record.last_used_at = time.time()
83
+ if url is not None:
84
+ record.url = url
85
+ if title is not None:
86
+ record.title = title
87
+ if last_snapshot_refs is not None:
88
+ record.last_snapshot_refs = set(last_snapshot_refs)
89
+ if last_snapshot_id is not None:
90
+ record.last_snapshot_id = last_snapshot_id
91
+ if last_snapshot_ref_count is not None:
92
+ record.last_snapshot_ref_count = last_snapshot_ref_count
93
+ if last_snapshot_url is not None:
94
+ record.last_snapshot_url = last_snapshot_url
95
+ if last_snapshot_at is not None:
96
+ record.last_snapshot_at = last_snapshot_at
97
+ return record
98
+
99
+ async def list_tabs(self, agent_id: str) -> list[TabRecord]:
100
+ async with self._lock:
101
+ active_page_id = self._active_tab_by_agent.get(agent_id)
102
+ visible = [
103
+ self._copy_record(record)
104
+ for record in self._tabs.values()
105
+ if record.owner_agent_id == agent_id
106
+ ]
107
+ visible.sort(key=lambda item: item.created_at)
108
+ if active_page_id:
109
+ visible.sort(key=lambda item: item.page_id != active_page_id)
110
+ return visible
111
+
112
+ async def has_tabs(self, agent_id: str) -> bool:
113
+ async with self._lock:
114
+ return any(record.owner_agent_id == agent_id for record in self._tabs.values())
115
+
116
+ async def get_active_tab(self, agent_id: str) -> TabRecord:
117
+ async with self._lock:
118
+ page_id = self._active_tab_by_agent.get(agent_id)
119
+ if page_id is None:
120
+ raise NoActiveTabError()
121
+ record = self._tabs.get(page_id)
122
+ if record is None or record.owner_agent_id != agent_id:
123
+ raise NoActiveTabError()
124
+ return self._copy_record(record)
125
+
126
+ async def set_active_tab(self, agent_id: str, page_id: str) -> TabRecord:
127
+ async with self._lock:
128
+ record = self._tabs.get(page_id)
129
+ if record is None or record.owner_agent_id != agent_id:
130
+ raise TabNotFoundError()
131
+ self._active_tab_by_agent[agent_id] = page_id
132
+ record.last_used_at = time.time()
133
+ return self._copy_record(record)
134
+
135
+ async def get_tab(self, agent_id: str, page_id: str) -> TabRecord:
136
+ async with self._lock:
137
+ record = self._tabs.get(page_id)
138
+ if record is None or record.owner_agent_id != agent_id:
139
+ raise TabNotFoundError()
140
+ return self._copy_record(record)
141
+
142
+ async def remove_tab(self, agent_id: str, page_id: str) -> TabRecord:
143
+ async with self._lock:
144
+ record = self._tabs.get(page_id)
145
+ if record is None or record.owner_agent_id != agent_id:
146
+ raise TabNotFoundError()
147
+ removed = self._tabs.pop(page_id)
148
+ active_page_id = self._active_tab_by_agent.get(agent_id)
149
+ if active_page_id == page_id:
150
+ replacement = self._find_latest_page_id_locked(agent_id)
151
+ if replacement is None:
152
+ self._active_tab_by_agent.pop(agent_id, None)
153
+ else:
154
+ self._active_tab_by_agent[agent_id] = replacement
155
+ return self._copy_record(removed)
156
+
157
+ async def current_active_page_id(self, agent_id: str) -> str:
158
+ return (await self.get_active_tab(agent_id)).page_id
159
+
160
+ async def snapshot_state(self) -> tuple[list[TabRecord], dict[str, str]]:
161
+ async with self._lock:
162
+ records = [self._copy_record(record) for record in self._tabs.values()]
163
+ records.sort(key=lambda item: item.created_at)
164
+ return records, dict(self._active_tab_by_agent)
165
+
166
+ async def replace_tab_ids(self, replacements: dict[str, str]) -> None:
167
+ if not replacements:
168
+ return
169
+ async with self._lock:
170
+ new_tabs: dict[str, TabRecord] = {}
171
+ for old_page_id, record in list(self._tabs.items()):
172
+ new_page_id = replacements.get(old_page_id, old_page_id)
173
+ new_record = self._copy_record(record)
174
+ new_record.page_id = new_page_id
175
+ new_tabs[new_page_id] = new_record
176
+ self._tabs = new_tabs
177
+ for agent_id, page_id in list(self._active_tab_by_agent.items()):
178
+ self._active_tab_by_agent[agent_id] = replacements.get(page_id, page_id)
179
+
180
+ async def clear_snapshot_state(self) -> None:
181
+ async with self._lock:
182
+ for record in self._tabs.values():
183
+ record.last_snapshot_refs = set()
184
+ record.last_snapshot_id = None
185
+ record.last_snapshot_ref_count = 0
186
+ record.last_snapshot_url = ""
187
+ record.last_snapshot_at = None
188
+
189
+ async def clear(self) -> None:
190
+ async with self._lock:
191
+ self._tabs.clear()
192
+ self._active_tab_by_agent.clear()
193
+
194
+ async def is_visible(self, agent_id: str, page_id: str) -> bool:
195
+ async with self._lock:
196
+ record = self._tabs.get(page_id)
197
+ return bool(record and record.owner_agent_id == agent_id)
198
+
199
+ @asynccontextmanager
200
+ async def claim_active_tab(
201
+ self,
202
+ *,
203
+ agent_id: str,
204
+ request_id: str,
205
+ command: str,
206
+ ) -> AsyncIterator[TabRecord]:
207
+ page_id: str
208
+ async with self._lock:
209
+ page_id = self._active_tab_by_agent.get(agent_id, "")
210
+ if not page_id:
211
+ raise NoActiveTabError()
212
+ record = self._tabs.get(page_id)
213
+ if record is None or record.owner_agent_id != agent_id:
214
+ raise NoActiveTabError()
215
+ if record.busy and record.busy.request_id != request_id:
216
+ raise BusyTabError()
217
+ record.busy = BusyState(
218
+ request_id=request_id,
219
+ command=command,
220
+ agent_id=agent_id,
221
+ started_at=time.time(),
222
+ )
223
+ record.last_used_at = time.time()
224
+ claimed = self._copy_record(record)
225
+ try:
226
+ yield claimed
227
+ finally:
228
+ await self.release_tab(page_id=page_id, request_id=request_id)
229
+
230
+ async def claim_page(
231
+ self,
232
+ *,
233
+ agent_id: str,
234
+ page_id: str,
235
+ request_id: str,
236
+ command: str,
237
+ ) -> TabRecord:
238
+ async with self._lock:
239
+ record = self._tabs.get(page_id)
240
+ if record is None or record.owner_agent_id != agent_id:
241
+ raise TabNotFoundError()
242
+ if record.busy and record.busy.request_id != request_id:
243
+ raise BusyTabError()
244
+ record.busy = BusyState(
245
+ request_id=request_id,
246
+ command=command,
247
+ agent_id=agent_id,
248
+ started_at=time.time(),
249
+ )
250
+ record.last_used_at = time.time()
251
+ return self._copy_record(record)
252
+
253
+ async def release_tab(self, *, page_id: str, request_id: str) -> None:
254
+ async with self._lock:
255
+ record = self._tabs.get(page_id)
256
+ if record is None:
257
+ return
258
+ if record.busy and record.busy.request_id == request_id:
259
+ record.busy = None
260
+
261
+ def _find_latest_page_id_locked(self, agent_id: str) -> str | None:
262
+ candidates = [record for record in self._tabs.values() if record.owner_agent_id == agent_id]
263
+ if not candidates:
264
+ return None
265
+ candidates.sort(key=lambda item: item.last_used_at, reverse=True)
266
+ return candidates[0].page_id
267
+
268
+ @staticmethod
269
+ def _copy_record(record: TabRecord) -> TabRecord:
270
+ return TabRecord(
271
+ page_id=record.page_id,
272
+ owner_agent_id=record.owner_agent_id,
273
+ created_at=record.created_at,
274
+ last_used_at=record.last_used_at,
275
+ url=record.url,
276
+ title=record.title,
277
+ busy=record.busy,
278
+ last_snapshot_refs=set(record.last_snapshot_refs),
279
+ last_snapshot_id=record.last_snapshot_id,
280
+ last_snapshot_ref_count=record.last_snapshot_ref_count,
281
+ last_snapshot_url=record.last_snapshot_url,
282
+ last_snapshot_at=record.last_snapshot_at,
283
+ )
@@ -0,0 +1,31 @@
1
+ """Thin Python runtime for Browser CLI-backed tasks."""
2
+
3
+ from browser_cli.task_runtime.client import BrowserCliTaskClient
4
+ from browser_cli.task_runtime.entrypoint import (
5
+ load_task_entrypoint,
6
+ parse_input_overrides,
7
+ run_task_entrypoint,
8
+ validate_task_dir,
9
+ )
10
+ from browser_cli.task_runtime.flow import Flow
11
+ from browser_cli.task_runtime.models import (
12
+ FlowContext,
13
+ SnapshotRef,
14
+ SnapshotResult,
15
+ validate_task_metadata,
16
+ )
17
+ from browser_cli.task_runtime.read import ReadResult
18
+
19
+ __all__ = [
20
+ "BrowserCliTaskClient",
21
+ "Flow",
22
+ "FlowContext",
23
+ "ReadResult",
24
+ "SnapshotRef",
25
+ "SnapshotResult",
26
+ "load_task_entrypoint",
27
+ "parse_input_overrides",
28
+ "run_task_entrypoint",
29
+ "validate_task_dir",
30
+ "validate_task_metadata",
31
+ ]
@@ -0,0 +1,107 @@
1
+ """Thin Browser CLI Python client for task execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from browser_cli.daemon.client import send_command
9
+ from browser_cli.profiles.discovery import ChromeEnvironment
10
+ from browser_cli.task_runtime.models import SnapshotResult
11
+ from browser_cli.task_runtime.read import ReadRequest, ReadResult, run_read_request
12
+
13
+
14
+ class BrowserCliTaskClient:
15
+ def __init__(self, *, chrome_environment: ChromeEnvironment | None = None) -> None:
16
+ self._chrome_environment = chrome_environment
17
+
18
+ def invoke(self, action: str, **args: Any) -> dict[str, Any]:
19
+ response = send_command(action, args)
20
+ return dict(response.get("data") or {})
21
+
22
+ def command(self, action: str, **args: Any) -> dict[str, Any]:
23
+ return self.invoke(action, **args)
24
+
25
+ def read(
26
+ self,
27
+ url: str,
28
+ *,
29
+ output_mode: str = "html",
30
+ scroll_bottom: bool = False,
31
+ ) -> ReadResult:
32
+ return asyncio.run(
33
+ run_read_request(
34
+ ReadRequest(
35
+ url=url,
36
+ output_mode=output_mode,
37
+ scroll_bottom=scroll_bottom,
38
+ ),
39
+ chrome_environment=self._chrome_environment,
40
+ )
41
+ )
42
+
43
+ def open(self, url: str) -> dict[str, Any]:
44
+ return self.invoke("open", url=url).get("page", {})
45
+
46
+ def search(self, query: str, *, engine: str = "duckduckgo") -> dict[str, Any]:
47
+ return self.invoke("search", query=query, engine=engine).get("page", {})
48
+
49
+ def snapshot(self, *, interactive: bool = False, full_page: bool = True) -> SnapshotResult:
50
+ return SnapshotResult.from_payload(
51
+ self.invoke("snapshot", interactive=interactive, full_page=full_page)
52
+ )
53
+
54
+ def html(self) -> str:
55
+ return str(self.invoke("html").get("html") or "")
56
+
57
+ def click(self, ref: str) -> dict[str, Any]:
58
+ return self.invoke("click", ref=ref)
59
+
60
+ def fill(self, ref: str, text: str, *, submit: bool = False) -> dict[str, Any]:
61
+ return self.invoke("fill", ref=ref, text=text, submit=submit)
62
+
63
+ def select(self, ref: str, text: str) -> dict[str, Any]:
64
+ return self.invoke("select", ref=ref, text=text)
65
+
66
+ def check(self, ref: str) -> dict[str, Any]:
67
+ return self.invoke("check", ref=ref)
68
+
69
+ def uncheck(self, ref: str) -> dict[str, Any]:
70
+ return self.invoke("uncheck", ref=ref)
71
+
72
+ def focus(self, ref: str) -> dict[str, Any]:
73
+ return self.invoke("focus", ref=ref)
74
+
75
+ def hover(self, ref: str) -> dict[str, Any]:
76
+ return self.invoke("hover", ref=ref)
77
+
78
+ def wait(self, seconds: float) -> dict[str, Any]:
79
+ return self.invoke("wait", seconds=seconds)
80
+
81
+ def wait_text(
82
+ self, text: str, *, timeout: float = 5.0, gone: bool = False, exact: bool = False
83
+ ) -> dict[str, Any]:
84
+ return self.invoke("wait", seconds=timeout, text=text, gone=gone, exact=exact)
85
+
86
+ def eval(self, code: str) -> Any:
87
+ return self.invoke("eval", code=code).get("result")
88
+
89
+ def eval_on(self, ref: str, code: str) -> Any:
90
+ return self.invoke("eval-on", ref=ref, code=code).get("result")
91
+
92
+ def verify_text(
93
+ self, text: str, *, exact: bool = False, timeout: float = 5.0
94
+ ) -> dict[str, Any]:
95
+ return self.invoke("verify-text", text=text, exact=exact, timeout=timeout)
96
+
97
+ def verify_state(self, ref: str, state: str) -> dict[str, Any]:
98
+ return self.invoke("verify-state", ref=ref, state=state)
99
+
100
+ def verify_value(self, ref: str, expected: str) -> dict[str, Any]:
101
+ return self.invoke("verify-value", ref=ref, expected=expected)
102
+
103
+ def screenshot(self, path: str, *, full_page: bool = False) -> dict[str, Any]:
104
+ return self.invoke("screenshot", path=path, full_page=full_page)
105
+
106
+ def close(self) -> dict[str, Any]:
107
+ return self.invoke("close")