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,280 @@
1
+ """Client helpers for daemon-backed commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import os
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ import uuid
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from browser_cli import __version__
18
+ from browser_cli.agent_scope import resolve_agent_id
19
+ from browser_cli.constants import get_app_paths
20
+ from browser_cli.errors import (
21
+ AmbiguousRefError,
22
+ BrowserCliError,
23
+ BrowserUnavailableError,
24
+ BusyTabError,
25
+ DaemonNotAvailableError,
26
+ EmptyContentError,
27
+ InvalidInputError,
28
+ NoActiveTabError,
29
+ NoSnapshotContextError,
30
+ NoVisibleTabsError,
31
+ OperationFailedError,
32
+ ProfileUnavailableError,
33
+ RefNotFoundError,
34
+ StaleSnapshotError,
35
+ TabNotFoundError,
36
+ TemporaryReadError,
37
+ )
38
+
39
+ from .transport import (
40
+ DAEMON_RUNTIME_VERSION,
41
+ ensure_run_dir,
42
+ probe_socket,
43
+ read_run_info,
44
+ remove_run_info,
45
+ safe_remove_socket,
46
+ )
47
+
48
+ STARTUP_TIMEOUT_SECONDS = 15.0
49
+ STARTUP_PROBE_INTERVAL_SECONDS = 0.1
50
+ TERMINATE_GRACE_SECONDS = 3.0
51
+
52
+
53
+ def send_command(
54
+ action: str,
55
+ args: dict[str, Any] | None = None,
56
+ *,
57
+ start_if_needed: bool = True,
58
+ ) -> dict[str, Any]:
59
+ if action == "stop" and not probe_socket():
60
+ return {
61
+ "ok": True,
62
+ "data": {"stopped": False, "already_stopped": True},
63
+ "meta": {"action": "stop", "agent_id": resolve_agent_id()},
64
+ }
65
+ if start_if_needed:
66
+ ensure_daemon_running()
67
+ elif not probe_socket():
68
+ raise DaemonNotAvailableError()
69
+ return asyncio.run(_send_command_async(action, args or {}))
70
+
71
+
72
+ async def _send_command_async(action: str, args: dict[str, Any]) -> dict[str, Any]:
73
+ app_paths = get_app_paths()
74
+ try:
75
+ reader, writer = await asyncio.open_unix_connection(str(app_paths.socket_path))
76
+ except OSError as exc:
77
+ raise DaemonNotAvailableError(str(exc)) from exc
78
+ request_payload = {
79
+ "action": action,
80
+ "args": args,
81
+ "agent_id": resolve_agent_id(),
82
+ "request_id": uuid.uuid4().hex,
83
+ }
84
+ writer.write((json.dumps(request_payload) + "\n").encode("utf-8"))
85
+ await writer.drain()
86
+ raw = await reader.readline()
87
+ writer.close()
88
+ with contextlib.suppress(Exception):
89
+ await writer.wait_closed()
90
+ if not raw:
91
+ raise DaemonNotAvailableError("Browser daemon closed the connection without a response.")
92
+ payload = json.loads(raw.decode("utf-8"))
93
+ if payload.get("ok"):
94
+ return payload
95
+ raise _error_from_payload(payload)
96
+
97
+
98
+ def ensure_daemon_running() -> None:
99
+ run_info = read_run_info()
100
+ if probe_socket() and _run_info_is_compatible(run_info):
101
+ return
102
+ if probe_socket():
103
+ _cleanup_stale_runtime()
104
+ if probe_socket():
105
+ return
106
+ _cleanup_stale_runtime()
107
+ if probe_socket():
108
+ return
109
+ _spawn_daemon()
110
+
111
+
112
+ def wait_for_daemon_stop(*, timeout_seconds: float = TERMINATE_GRACE_SECONDS) -> bool:
113
+ deadline = time.time() + timeout_seconds
114
+ while time.time() < deadline:
115
+ if not probe_socket():
116
+ return True
117
+ time.sleep(0.1)
118
+ return not probe_socket()
119
+
120
+
121
+ def cleanup_runtime(*, fast_kill: bool = False) -> bool:
122
+ app_paths = get_app_paths()
123
+ had_runtime_state = bool(read_run_info() is not None or app_paths.socket_path.exists())
124
+ _cleanup_stale_runtime(fast_kill=fast_kill)
125
+ return had_runtime_state
126
+
127
+
128
+ def run_info_is_compatible(run_info: dict[str, Any] | None) -> bool:
129
+ return _run_info_is_compatible(run_info)
130
+
131
+
132
+ def _spawn_daemon() -> None:
133
+ ensure_run_dir()
134
+ app_paths = get_app_paths()
135
+ existing_pythonpath = os.environ.get("PYTHONPATH", "")
136
+ source_root = Path(__file__).resolve().parents[2]
137
+ pythonpath_parts = [str(source_root)]
138
+ if existing_pythonpath:
139
+ pythonpath_parts.append(existing_pythonpath)
140
+ env = os.environ.copy()
141
+ env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts)
142
+ log_handle = app_paths.daemon_log_path.open("ab")
143
+ process = None
144
+ try:
145
+ process = subprocess.Popen(
146
+ _build_daemon_command(app_paths.home),
147
+ stdout=log_handle,
148
+ stderr=subprocess.STDOUT,
149
+ env=env,
150
+ start_new_session=True,
151
+ )
152
+ finally:
153
+ log_handle.close()
154
+ if _wait_for_socket(timeout_seconds=STARTUP_TIMEOUT_SECONDS):
155
+ return
156
+ if process is not None:
157
+ _terminate_process_tree(process.pid)
158
+ run_info = read_run_info()
159
+ details = f"run_info={run_info}" if run_info else f"log={app_paths.daemon_log_path}"
160
+ raise DaemonNotAvailableError(f"Timed out waiting for browser daemon startup ({details}).")
161
+
162
+
163
+ def _build_daemon_command(home: Path) -> list[str]:
164
+ return [sys.executable, "-m", "browser_cli.daemon", "--home", str(home)]
165
+
166
+
167
+ def _wait_for_socket(*, timeout_seconds: float) -> bool:
168
+ deadline = time.time() + timeout_seconds
169
+ while time.time() < deadline:
170
+ if probe_socket():
171
+ return True
172
+ time.sleep(STARTUP_PROBE_INTERVAL_SECONDS)
173
+ return False
174
+
175
+
176
+ def _cleanup_stale_runtime(*, fast_kill: bool = False) -> None:
177
+ run_info = read_run_info()
178
+ socket_reachable = probe_socket()
179
+ if run_info is not None:
180
+ pid = _coerce_pid(run_info.get("pid"))
181
+ if pid is not None and _pid_exists(pid):
182
+ if fast_kill and not socket_reachable:
183
+ _signal_process_tree(pid, signal.SIGKILL)
184
+ _wait_for_pid_exit(pid, timeout_seconds=1.0)
185
+ else:
186
+ _terminate_process_tree(pid)
187
+ remove_run_info()
188
+ with contextlib.suppress(FileNotFoundError):
189
+ safe_remove_socket()
190
+
191
+
192
+ def _run_info_is_compatible(run_info: dict[str, Any] | None) -> bool:
193
+ if not isinstance(run_info, dict):
194
+ return False
195
+ return (
196
+ str(run_info.get("package_version") or "") == __version__
197
+ and str(run_info.get("runtime_version") or "") == DAEMON_RUNTIME_VERSION
198
+ )
199
+
200
+
201
+ def _coerce_pid(value: Any) -> int | None:
202
+ try:
203
+ pid = int(value)
204
+ except (TypeError, ValueError):
205
+ return None
206
+ return pid if pid > 0 else None
207
+
208
+
209
+ def _pid_exists(pid: int) -> bool:
210
+ try:
211
+ os.kill(pid, 0)
212
+ except ProcessLookupError:
213
+ return False
214
+ except PermissionError:
215
+ return True
216
+ else:
217
+ return True
218
+
219
+
220
+ def _terminate_process_tree(pid: int) -> None:
221
+ _signal_process_tree(pid, signal.SIGTERM)
222
+ if _wait_for_pid_exit(pid, timeout_seconds=TERMINATE_GRACE_SECONDS):
223
+ return
224
+ _signal_process_tree(pid, signal.SIGKILL)
225
+ _wait_for_pid_exit(pid, timeout_seconds=1.0)
226
+
227
+
228
+ def _signal_process_tree(pid: int, sig: signal.Signals) -> None:
229
+ try:
230
+ os.killpg(pid, sig)
231
+ except ProcessLookupError:
232
+ return
233
+ except OSError:
234
+ try:
235
+ os.kill(pid, sig)
236
+ except OSError:
237
+ return
238
+
239
+
240
+ def _wait_for_pid_exit(pid: int, *, timeout_seconds: float) -> bool:
241
+ deadline = time.time() + timeout_seconds
242
+ while time.time() < deadline:
243
+ if not _pid_exists(pid):
244
+ return True
245
+ time.sleep(0.1)
246
+ return not _pid_exists(pid)
247
+
248
+
249
+ def _error_from_payload(payload: dict[str, Any]) -> BrowserCliError:
250
+ error_code = str(payload.get("error_code") or "OPERATION_FAILED")
251
+ message = str(payload.get("error_message") or "Daemon command failed.")
252
+ if error_code == "INVALID_INPUT":
253
+ return InvalidInputError(message)
254
+ if error_code == "NO_ACTIVE_TAB":
255
+ return NoActiveTabError(message)
256
+ if error_code == "NO_VISIBLE_TABS":
257
+ return NoVisibleTabsError(message)
258
+ if error_code == "AGENT_ACTIVE_TAB_BUSY":
259
+ return BusyTabError(message)
260
+ if error_code == "TAB_NOT_FOUND":
261
+ return TabNotFoundError(message)
262
+ if error_code == "REF_NOT_FOUND":
263
+ return RefNotFoundError(message)
264
+ if error_code == "NO_SNAPSHOT_CONTEXT":
265
+ return NoSnapshotContextError(message)
266
+ if error_code == "AMBIGUOUS_REF":
267
+ return AmbiguousRefError(message)
268
+ if error_code == "STALE_SNAPSHOT":
269
+ return StaleSnapshotError(message)
270
+ if error_code == "DAEMON_NOT_AVAILABLE":
271
+ return DaemonNotAvailableError(message)
272
+ if error_code == "BROWSER_UNAVAILABLE":
273
+ return BrowserUnavailableError(message)
274
+ if error_code == "PROFILE_UNAVAILABLE":
275
+ return ProfileUnavailableError(message)
276
+ if error_code == "EMPTY_CONTENT":
277
+ return EmptyContentError(message)
278
+ if error_code == "TEMPORARY_FAILURE":
279
+ return TemporaryReadError(message)
280
+ return OperationFailedError(message, error_code=error_code)
@@ -0,0 +1,63 @@
1
+ """Daemon request and response models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class DaemonRequest:
11
+ action: str
12
+ args: dict[str, Any]
13
+ agent_id: str
14
+ request_id: str
15
+
16
+ @classmethod
17
+ def from_dict(cls, payload: dict[str, Any]) -> DaemonRequest:
18
+ return cls(
19
+ action=str(payload.get("action") or "").strip(),
20
+ args=dict(payload.get("args") or {}),
21
+ agent_id=str(payload.get("agent_id") or "").strip(),
22
+ request_id=str(payload.get("request_id") or "").strip(),
23
+ )
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class DaemonResponse:
28
+ ok: bool
29
+ data: dict[str, Any] | None = None
30
+ meta: dict[str, Any] = field(default_factory=dict)
31
+ error_code: str | None = None
32
+ error_message: str | None = None
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ payload: dict[str, Any] = {
36
+ "ok": self.ok,
37
+ "data": self.data or {},
38
+ "meta": self.meta,
39
+ }
40
+ if not self.ok:
41
+ payload["error_code"] = self.error_code
42
+ payload["error_message"] = self.error_message
43
+ return payload
44
+
45
+ @classmethod
46
+ def success(cls, data: dict[str, Any], *, meta: dict[str, Any] | None = None) -> DaemonResponse:
47
+ return cls(ok=True, data=data, meta=meta or {})
48
+
49
+ @classmethod
50
+ def failure(
51
+ cls,
52
+ *,
53
+ error_code: str,
54
+ error_message: str,
55
+ meta: dict[str, Any] | None = None,
56
+ ) -> DaemonResponse:
57
+ return cls(
58
+ ok=False,
59
+ data={},
60
+ meta=meta or {},
61
+ error_code=error_code,
62
+ error_message=error_message,
63
+ )
@@ -0,0 +1,167 @@
1
+ """Daemon-owned runtime presentation model derived from raw runtime facts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def build_runtime_presentation(raw_status: dict[str, Any]) -> dict[str, Any]:
9
+ browser_started = bool(raw_status.get("browser_started"))
10
+ active_driver = str(raw_status.get("active_driver") or "not-started")
11
+ pending_rebind = _as_dict_or_none(raw_status.get("pending_rebind"))
12
+ extension = dict(raw_status.get("extension") or {})
13
+ workspace_window_state = dict(raw_status.get("workspace_window_state") or {})
14
+ tabs = dict(raw_status.get("tabs") or {})
15
+ busy_tab_count = int(tabs.get("busy_count") or 0)
16
+ binding_state = str(workspace_window_state.get("binding_state") or "absent")
17
+ extension_connected = bool(extension.get("connected"))
18
+ capability_complete = bool(extension.get("capability_complete"))
19
+ stability = dict(raw_status.get("stability") or {})
20
+ cleanup_failures = int(stability.get("cleanup_failures") or 0)
21
+ last_cleanup_error = str(stability.get("last_cleanup_error") or "").strip() or None
22
+
23
+ execution_path = {
24
+ "active_driver": active_driver,
25
+ "pending_rebind": pending_rebind,
26
+ "safe_point_wait": pending_rebind is not None,
27
+ "last_transition": _as_dict_or_none(raw_status.get("last_transition")),
28
+ }
29
+ workspace_state = {
30
+ "window_id": workspace_window_state.get("window_id"),
31
+ "tab_count": int(workspace_window_state.get("tab_count") or 0),
32
+ "managed_tab_count": int(workspace_window_state.get("managed_tab_count") or 0),
33
+ "binding_state": binding_state,
34
+ "busy_tab_count": busy_tab_count,
35
+ }
36
+
37
+ overall_state = "healthy"
38
+ summary_reason = "Browser CLI runtime is healthy."
39
+ recovery_guidance = ["Browser CLI can continue normally."]
40
+
41
+ if not browser_started:
42
+ summary_reason = "Browser runtime is idle and will start on the next browser command."
43
+ recovery_guidance = ["Run a browser command to initialize runtime state."]
44
+ elif active_driver == "not-started":
45
+ overall_state = "broken"
46
+ summary_reason = "Browser runtime reports started but no active driver is selected."
47
+ recovery_guidance = [
48
+ "Refresh runtime status to confirm the state.",
49
+ "Reload Browser CLI if the runtime remains driverless.",
50
+ ]
51
+ elif pending_rebind is not None:
52
+ overall_state = "recovering"
53
+ summary_reason = _pending_rebind_summary(pending_rebind)
54
+ recovery_guidance = _pending_rebind_guidance(pending_rebind)
55
+ elif active_driver != "extension":
56
+ overall_state = "degraded"
57
+ summary_reason = "Browser CLI is running on Playwright instead of extension mode."
58
+ recovery_guidance = [
59
+ "Agent can continue on the managed profile backend.",
60
+ "Reconnect the extension if real Chrome mode should be restored.",
61
+ ]
62
+ elif not extension_connected or not capability_complete:
63
+ overall_state = "degraded"
64
+ if extension_connected:
65
+ summary_reason = "Extension is connected but its required capabilities are incomplete."
66
+ else:
67
+ summary_reason = "Extension mode is selected but the extension is disconnected."
68
+ recovery_guidance = [
69
+ "Reconnect or reload the extension.",
70
+ "Refresh runtime status after extension health changes.",
71
+ ]
72
+ elif binding_state in {"stale", "absent"}:
73
+ overall_state = "degraded"
74
+ if binding_state == "stale":
75
+ summary_reason = "Workspace binding is stale while extension mode is active."
76
+ else:
77
+ summary_reason = "Browser CLI no longer has a trusted extension workspace binding."
78
+ recovery_guidance = [
79
+ "Rebuild workspace binding to restore Browser CLI-owned tab tracking.",
80
+ "Reconnect the extension if workspace state does not recover.",
81
+ ]
82
+ elif cleanup_failures:
83
+ overall_state = "degraded"
84
+ summary_reason = "Browser CLI recorded recent cleanup failures during runtime transitions."
85
+ recovery_guidance = [
86
+ "Run `browser-cli reload` if cleanup failures continue to appear.",
87
+ "Refresh runtime status after the reload finishes.",
88
+ ]
89
+
90
+ return {
91
+ "overall_state": overall_state,
92
+ "summary_reason": summary_reason,
93
+ "execution_path": execution_path,
94
+ "workspace_state": workspace_state,
95
+ "stability": {
96
+ **stability,
97
+ "last_cleanup_error": last_cleanup_error,
98
+ },
99
+ "recovery_guidance": recovery_guidance,
100
+ "available_actions": _available_actions(
101
+ active_driver=active_driver,
102
+ pending_rebind=pending_rebind,
103
+ extension_connected=extension_connected,
104
+ capability_complete=capability_complete,
105
+ binding_state=binding_state,
106
+ ),
107
+ }
108
+
109
+
110
+ def _available_actions(
111
+ *,
112
+ active_driver: str,
113
+ pending_rebind: dict[str, Any] | None,
114
+ extension_connected: bool,
115
+ capability_complete: bool,
116
+ binding_state: str,
117
+ ) -> list[str]:
118
+ actions = ["refresh-status"]
119
+ if (
120
+ pending_rebind is not None
121
+ or active_driver != "extension"
122
+ or not extension_connected
123
+ or not capability_complete
124
+ ):
125
+ actions.append("reconnect-extension")
126
+ if active_driver == "extension" and binding_state in {"stale", "absent"}:
127
+ if "reconnect-extension" not in actions:
128
+ actions.append("reconnect-extension")
129
+ actions.append("rebuild-workspace-binding")
130
+ return actions
131
+
132
+
133
+ def _pending_rebind_summary(pending_rebind: dict[str, Any]) -> str:
134
+ target = str(pending_rebind.get("target") or "unknown")
135
+ reason = str(pending_rebind.get("reason") or "pending-rebind")
136
+ if reason == "extension-disconnected-waiting-command" and target == "playwright":
137
+ return (
138
+ "Extension disconnected; Browser CLI will switch to Playwright at the next safe point."
139
+ )
140
+ if reason == "extension-connected" and target == "extension":
141
+ return (
142
+ "Extension reconnected; Browser CLI will restore extension mode at the next safe point."
143
+ )
144
+ return f"Browser CLI is waiting to rebind the active driver to {target} at the next safe point."
145
+
146
+
147
+ def _pending_rebind_guidance(pending_rebind: dict[str, Any]) -> list[str]:
148
+ target = str(pending_rebind.get("target") or "unknown")
149
+ if target == "playwright":
150
+ return [
151
+ "Agent can continue while Browser CLI waits for the safe-point fallback.",
152
+ "Reconnect the extension if real Chrome mode should be restored later.",
153
+ ]
154
+ if target == "extension":
155
+ return [
156
+ "Wait for the current command boundary, then refresh runtime status.",
157
+ "Browser CLI will resume extension mode at the next safe point.",
158
+ ]
159
+ return [
160
+ "Wait for the current command boundary, then refresh runtime status.",
161
+ ]
162
+
163
+
164
+ def _as_dict_or_none(value: Any) -> dict[str, Any] | None:
165
+ if isinstance(value, dict):
166
+ return dict(value)
167
+ return None
@@ -0,0 +1,103 @@
1
+ """Daemon server entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import logging
9
+ import os
10
+ import signal
11
+ import time
12
+
13
+ from browser_cli import __version__
14
+ from browser_cli.constants import get_app_paths
15
+
16
+ from .app import BrowserDaemonApp
17
+ from .models import DaemonRequest, DaemonResponse
18
+ from .transport import (
19
+ DAEMON_RUNTIME_VERSION,
20
+ ensure_run_dir,
21
+ remove_run_info,
22
+ safe_remove_socket,
23
+ write_run_info,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class BrowserDaemonServer:
30
+ def __init__(self, app: BrowserDaemonApp | None = None) -> None:
31
+ self._app = app or BrowserDaemonApp()
32
+ self._server: asyncio.AbstractServer | None = None
33
+
34
+ async def serve(self) -> None:
35
+ app_paths = get_app_paths()
36
+ logger.info("Starting Browser CLI daemon")
37
+ loop = asyncio.get_running_loop()
38
+ installed_signal_handlers: list[signal.Signals] = []
39
+ for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
40
+ try:
41
+ loop.add_signal_handler(sig, self._app.state.shutdown_event.set)
42
+ installed_signal_handlers.append(sig)
43
+ except (NotImplementedError, RuntimeError):
44
+ pass
45
+ ensure_run_dir()
46
+ if app_paths.socket_path.exists():
47
+ safe_remove_socket(app_paths.socket_path)
48
+ try:
49
+ await self._app.state.browser_service.ensure_extension_listener_started()
50
+ self._server = await asyncio.start_unix_server(
51
+ self._handle_client,
52
+ path=str(app_paths.socket_path),
53
+ )
54
+ with contextlib.suppress(OSError):
55
+ os.chmod(app_paths.socket_path, 0o600)
56
+ write_run_info(
57
+ {
58
+ "transport": "unix",
59
+ "socket": str(app_paths.socket_path),
60
+ "pid": os.getpid(),
61
+ "pgid": os.getpgrp(),
62
+ "home": str(app_paths.home),
63
+ "started_at": time.time(),
64
+ "package_version": __version__,
65
+ "runtime_version": DAEMON_RUNTIME_VERSION,
66
+ }
67
+ )
68
+ logger.info("Browser CLI daemon listening on %s", app_paths.socket_path)
69
+ await self._app.state.shutdown_event.wait()
70
+ finally:
71
+ logger.info("Stopping Browser CLI daemon")
72
+ if self._server is not None:
73
+ self._server.close()
74
+ await self._server.wait_closed()
75
+ self._server = None
76
+ await self._app.state.browser_service.stop()
77
+ remove_run_info()
78
+ with contextlib.suppress(FileNotFoundError):
79
+ safe_remove_socket(app_paths.socket_path)
80
+ for sig in installed_signal_handlers:
81
+ with contextlib.suppress(Exception):
82
+ loop.remove_signal_handler(sig)
83
+
84
+ async def _handle_client(
85
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
86
+ ) -> None:
87
+ try:
88
+ raw = await reader.readline()
89
+ if not raw:
90
+ return
91
+ payload = json.loads(raw.decode("utf-8"))
92
+ request = DaemonRequest.from_dict(payload)
93
+ response = await self._app.execute(request)
94
+ except Exception as exc: # pragma: no cover
95
+ response = DaemonResponse.failure(
96
+ error_code="DAEMON_PROTOCOL_ERROR",
97
+ error_message=str(exc),
98
+ )
99
+ writer.write((json.dumps(response.to_dict()) + "\n").encode("utf-8"))
100
+ await writer.drain()
101
+ writer.close()
102
+ with contextlib.suppress(Exception):
103
+ await writer.wait_closed()
@@ -0,0 +1,15 @@
1
+ """Shared daemon state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from browser_cli.daemon.browser_service import BrowserService
8
+ from browser_cli.tabs import TabRegistry
9
+
10
+
11
+ class DaemonState:
12
+ def __init__(self) -> None:
13
+ self.tabs = TabRegistry()
14
+ self.browser_service = BrowserService(self.tabs)
15
+ self.shutdown_event = asyncio.Event()