@pencil-agent/nano-pencil 2.0.0 → 2.0.1

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 (195) hide show
  1. package/README.md +267 -267
  2. package/dist/build-meta.json +3 -3
  3. package/dist/core/export-html/AGENT.md +11 -11
  4. package/dist/core/export-html/template.css +971 -971
  5. package/dist/core/export-html/template.html +54 -54
  6. package/dist/core/mcp/mcp-client.d.ts +3 -1
  7. package/dist/core/mcp/mcp-client.js +6 -6
  8. package/dist/core/mcp/mcp-config.d.ts +3 -3
  9. package/dist/core/mcp/mcp-config.js +1 -1
  10. package/dist/core/mcp/mcp-manager.d.ts +5 -1
  11. package/dist/core/mcp/mcp-manager.js +1 -1
  12. package/dist/core/platform/config/resource-loader.d.ts +2 -0
  13. package/dist/core/platform/config/resource-loader.js +2 -2
  14. package/dist/core/runtime/agent-session.d.ts +12 -0
  15. package/dist/core/runtime/agent-session.js +8 -8
  16. package/dist/core/runtime/sdk.d.ts +8 -0
  17. package/dist/core/runtime/sdk.js +1 -1
  18. package/dist/extensions/builtin/AGENT.md +115 -115
  19. package/dist/extensions/builtin/browser/AGENT.md +17 -17
  20. package/dist/extensions/builtin/browser/agent-workspace/agent_helpers.py +12 -12
  21. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/amazon/product-search.md +198 -198
  22. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/archive-org/scraping.md +341 -341
  23. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/arxiv/scraping.md +311 -311
  24. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/arxiv-bulk/scraping.md +333 -333
  25. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/atlas/overview.md +70 -70
  26. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/booking-com/scraping.md +578 -578
  27. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/capterra/scraping.md +440 -440
  28. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/centilebrain/generate-estimates.md +110 -110
  29. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coingecko/scraping.md +325 -325
  30. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coinmarketcap/scraping.md +463 -463
  31. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coursera/scraping.md +360 -360
  32. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/craigslist/scraping.md +390 -390
  33. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/crossref/scraping.md +568 -568
  34. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/dev-to/scraping.md +323 -323
  35. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/duckduckgo/scraping.md +349 -349
  36. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/ebay/scraping.md +435 -435
  37. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/etsy/scraping.md +506 -506
  38. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/eventbrite/scraping.md +363 -363
  39. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/expedia/automation.md +168 -168
  40. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/facebook/groups.md +236 -236
  41. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/facebook/pages.md +295 -295
  42. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/framer/editor.md +108 -108
  43. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/fred/scraping.md +493 -493
  44. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/g2/scraping.md +580 -580
  45. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/genius/scraping.md +511 -511
  46. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/github/repo-actions.md +65 -65
  47. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/github/scraping.md +184 -184
  48. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/glassdoor/scraping.md +543 -543
  49. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/gmail/compose.md +122 -122
  50. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/goodreads/scraping.md +461 -461
  51. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/gutenberg/scraping.md +383 -383
  52. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/hackernews/scraping.md +243 -243
  53. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/howlongtobeat/scraping.md +473 -473
  54. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/imdb/scraping.md +271 -271
  55. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/itch-io/scraping.md +436 -436
  56. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/job-boards/indeed-glassdoor.md +1021 -1021
  57. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/letterboxd/scraping.md +349 -349
  58. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/linkedin/invitation-manager.md +109 -109
  59. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/loom/folder-enumeration.md +170 -170
  60. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/macrotrends/scraping.md +537 -537
  61. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/medium/article-hydration.md +120 -120
  62. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/medium/scraping.md +414 -414
  63. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/metacritic/scraping.md +477 -477
  64. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/musicbrainz/scraping.md +478 -478
  65. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/nasa/scraping.md +339 -339
  66. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/news-aggregation/multi-source.md +205 -205
  67. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/open-library/scraping.md +472 -472
  68. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/openalex/scraping.md +470 -470
  69. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/openstreetmap/scraping.md +490 -490
  70. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/package-registries/npm-pypi.md +478 -478
  71. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/polymarket/scraping.md +234 -234
  72. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/producthunt/scraping.md +307 -307
  73. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/pubmed/scraping.md +421 -421
  74. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/quora/scraping.md +364 -364
  75. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/rawg/scraping.md +352 -352
  76. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/reddit/scraping.md +124 -124
  77. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/rest-countries/scraping.md +233 -233
  78. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/sec-edgar/scraping.md +361 -361
  79. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/README.md +36 -36
  80. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/embedded-apps.md +72 -72
  81. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/knowledge-base.md +109 -109
  82. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/polaris-inputs.md +137 -137
  83. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/soundcloud/scraping.md +362 -362
  84. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/spotify/scraping.md +339 -339
  85. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/stackoverflow/scraping.md +435 -435
  86. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/steam/scraping.md +575 -575
  87. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/substack/scraping.md +338 -338
  88. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/thetechgeeks/pricing.md +52 -52
  89. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/tiktok/upload.md +107 -107
  90. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/tradingview/scraping.md +309 -309
  91. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/trello/boards-and-lists.md +88 -88
  92. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/trustpilot/scraping.md +375 -375
  93. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/walmart/scraping.md +444 -444
  94. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/wayback-machine/scraping.md +306 -306
  95. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/weather/scraping.md +398 -398
  96. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/wellfound/scraping.md +596 -596
  97. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/world-bank/scraping.md +356 -356
  98. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/xiaohongshu/scraping.md +84 -84
  99. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/youtube/scraping.md +418 -418
  100. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/zillow/scraping.md +433 -433
  101. package/dist/extensions/builtin/browser/browser.md +73 -73
  102. package/dist/extensions/builtin/browser/install.md +142 -142
  103. package/dist/extensions/builtin/browser/interaction-skills/connection.md +48 -48
  104. package/dist/extensions/builtin/browser/interaction-skills/cookies.md +3 -3
  105. package/dist/extensions/builtin/browser/interaction-skills/cross-origin-iframes.md +3 -3
  106. package/dist/extensions/builtin/browser/interaction-skills/dialogs.md +64 -64
  107. package/dist/extensions/builtin/browser/interaction-skills/downloads.md +3 -3
  108. package/dist/extensions/builtin/browser/interaction-skills/drag-and-drop.md +3 -3
  109. package/dist/extensions/builtin/browser/interaction-skills/dropdowns.md +3 -3
  110. package/dist/extensions/builtin/browser/interaction-skills/iframes.md +3 -3
  111. package/dist/extensions/builtin/browser/interaction-skills/network-requests.md +3 -3
  112. package/dist/extensions/builtin/browser/interaction-skills/print-as-pdf.md +3 -3
  113. package/dist/extensions/builtin/browser/interaction-skills/profile-sync.md +90 -90
  114. package/dist/extensions/builtin/browser/interaction-skills/screenshots.md +17 -17
  115. package/dist/extensions/builtin/browser/interaction-skills/scrolling.md +3 -3
  116. package/dist/extensions/builtin/browser/interaction-skills/shadow-dom.md +3 -3
  117. package/dist/extensions/builtin/browser/interaction-skills/tabs.md +69 -69
  118. package/dist/extensions/builtin/browser/interaction-skills/uploads.md +1 -1
  119. package/dist/extensions/builtin/browser/interaction-skills/viewport.md +3 -3
  120. package/dist/extensions/builtin/browser/src/browser_harness/AGENT.md +15 -15
  121. package/dist/extensions/builtin/browser/src/browser_harness/__init__.py +8 -8
  122. package/dist/extensions/builtin/browser/src/browser_harness/_ipc.py +90 -90
  123. package/dist/extensions/builtin/browser/src/browser_harness/admin.py +722 -722
  124. package/dist/extensions/builtin/browser/src/browser_harness/daemon.py +328 -328
  125. package/dist/extensions/builtin/browser/src/browser_harness/helpers.py +396 -396
  126. package/dist/extensions/builtin/browser/src/browser_harness/run.py +103 -103
  127. package/dist/extensions/builtin/discipline/skills/brainstorming/SKILL.md +33 -33
  128. package/dist/extensions/builtin/discipline/skills/executing-plans/SKILL.md +25 -25
  129. package/dist/extensions/builtin/discipline/skills/finishing-development-branch/SKILL.md +25 -25
  130. package/dist/extensions/builtin/discipline/skills/receiving-code-review/SKILL.md +22 -22
  131. package/dist/extensions/builtin/discipline/skills/requesting-code-review/SKILL.md +31 -31
  132. package/dist/extensions/builtin/discipline/skills/systematic-debugging/SKILL.md +28 -28
  133. package/dist/extensions/builtin/discipline/skills/test-driven-development/SKILL.md +32 -32
  134. package/dist/extensions/builtin/discipline/skills/using-git-worktrees/SKILL.md +25 -25
  135. package/dist/extensions/builtin/discipline/skills/verification-before-completion/SKILL.md +27 -27
  136. package/dist/extensions/builtin/discipline/skills/writing-plans/SKILL.md +26 -26
  137. package/dist/extensions/builtin/goal/README.md +67 -67
  138. package/dist/extensions/builtin/grub/README.md +112 -112
  139. package/dist/extensions/builtin/link-world/agent-workspace/README.md +16 -16
  140. package/dist/extensions/builtin/link-world/internet-search/internet-search.md +65 -65
  141. package/dist/extensions/builtin/link-world/link-world-agent.md +82 -82
  142. package/dist/extensions/builtin/link-world/linkworld.md +313 -313
  143. package/dist/extensions/builtin/link-world/network-routing/network-routing.md +67 -67
  144. package/dist/extensions/builtin/loop/README.md +92 -92
  145. package/dist/extensions/builtin/mcp/figma-design.md +68 -68
  146. package/dist/extensions/builtin/mcp/mcp-management.md +85 -85
  147. package/dist/extensions/builtin/recap/AGENT.md +15 -15
  148. package/dist/extensions/builtin/sal/README.md +72 -72
  149. package/dist/extensions/builtin/security-audit/README.md +289 -289
  150. package/dist/extensions/builtin/team/AGENT.md +112 -112
  151. package/dist/extensions/builtin/team/TESTING.md +299 -299
  152. package/dist/extensions/builtin/token-save/README.md +56 -56
  153. package/dist/extensions/optional/AGENT.md +10 -10
  154. package/dist/modes/interactive/interactive-mode.js +36 -36
  155. package/dist/modes/interactive/theme/dark.json +85 -85
  156. package/dist/modes/interactive/theme/light.json +84 -84
  157. package/dist/modes/interactive/theme/theme-schema.json +335 -335
  158. package/dist/modes/interactive/theme/warm.json +81 -81
  159. package/dist/node_modules/@pencil-agent/agent-core/dist/agent-loop.js +3 -2
  160. package/dist/node_modules/@pencil-agent/agent-core/dist/structured-adaptive-agent-loop.js +2 -1
  161. package/dist/node_modules/@pencil-agent/ai/dist/cli.js +0 -0
  162. package/docs/cc-agent-design.md +1297 -0
  163. package/docs/cc-tui-design.md +1333 -0
  164. package/docs/codex-goal-command-impl.md +1055 -1055
  165. package/docs/codex-goal-vs-grub.md +500 -500
  166. package/docs/custom-provider.md +27 -27
  167. package/docs/extensions.md +27 -27
  168. package/docs/keybindings.md +27 -27
  169. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/200/273/347/273/223.md" +250 -250
  170. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/212/245/345/221/212.md" +122 -122
  171. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210.md" +1222 -1222
  172. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210/345/256/236/347/216/260/346/212/245/345/221/212.md" +158 -158
  173. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210/345/257/271/346/257/224/345/210/206/346/236/220.md" +128 -128
  174. package/docs/loop /351/207/215/346/236/204/350/256/241/345/210/222.md" +320 -320
  175. package/docs/loop-usage-examples.md +214 -214
  176. package/docs/models.md +27 -27
  177. package/docs/nanoPencil-/345/255/246/344/271/240/350/256/241/345/210/222.md +170 -0
  178. package/docs/packages.md +27 -27
  179. package/docs/pi-design-philosophy.md +457 -457
  180. package/docs/planmode.md +1987 -1987
  181. package/docs/prompt-templates.md +27 -27
  182. package/docs/providers.md +27 -27
  183. package/docs/scan-report.md +3820 -0
  184. package/docs/sdk.md +27 -27
  185. package/docs/skills.md +27 -27
  186. package/docs/themes.md +27 -27
  187. package/docs/tui.md +27 -27
  188. package/docs//345/257/271/346/240/207Claude-Code.md +1775 -0
  189. package/docs//351/230/277/351/207/214/345/267/264/345/267/264/350/264/242/346/212/245/345/210/206/346/236/220/344/271/246.md +261 -0
  190. package/package.json +190 -190
  191. package/docs/ACP/345/215/217/350/256/256/351/233/206/346/210/220/345/274/200/345/217/221/346/226/207/346/241/243.md +0 -851
  192. package/docs/SDK-TESTING.md +0 -364
  193. package/docs/mem-core/346/212/200/346/234/257/346/226/207/346/241/243.md +0 -593
  194. package/docs/startup-performance-optimization.md +0 -301
  195. package/docs//350/256/244/347/237/245/345/234/260/345/233/276.md +0 -47
@@ -1,722 +1,722 @@
1
- """Browser Harness admin commands and remote browser lifecycle.
2
-
3
- [WHO]: Provides daemon setup/doctor/reload/update helpers and Browser Use cloud browser/profile functions
4
- [FROM]: Depends on browser_harness._ipc, urllib, subprocess, socket, tempfile, pathlib, and environment variables
5
- [TO]: Consumed by browser_harness.run and user snippets through pre-imported admin helpers
6
- [HERE]: extensions/builtin/browser/src/browser_harness/admin.py within vendored Browser Harness package
7
- """
8
-
9
- import json
10
- import os
11
- import socket
12
- import tempfile
13
- import time
14
- import urllib.request
15
- from pathlib import Path
16
-
17
- from . import _ipc as ipc
18
-
19
-
20
- def _load_env():
21
- repo_root = Path(__file__).resolve().parents[2]
22
- workspace = Path(os.environ.get("BH_AGENT_WORKSPACE", repo_root / "agent-workspace")).expanduser()
23
- for p in (repo_root / ".env", workspace / ".env"):
24
- if not p.exists():
25
- continue
26
- _load_env_file(p)
27
-
28
-
29
- def _load_env_file(p):
30
- for line in p.read_text().splitlines():
31
- line = line.strip()
32
- if not line or line.startswith("#") or "=" not in line:
33
- continue
34
- k, v = line.split("=", 1)
35
- os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
36
-
37
-
38
- _load_env()
39
-
40
- NAME = os.environ.get("BU_NAME", "default")
41
- BU_API = "https://api.browser-use.com/api/v3"
42
- GH_RELEASES = "https://api.github.com/repos/browser-use/browser-harness/releases/latest"
43
- VERSION_CACHE = Path(tempfile.gettempdir()) / "bu-version-cache.json"
44
- VERSION_CACHE_TTL = 24 * 3600
45
- DOCTOR_TEXT_LIMIT = 140
46
-
47
-
48
- def _log_tail(name):
49
- try:
50
- return ipc.log_path(name or NAME).read_text().strip().splitlines()[-1]
51
- except (FileNotFoundError, IndexError):
52
- return None
53
-
54
-
55
- def _needs_chrome_remote_debugging_prompt(msg):
56
- """True when Chrome needs the inspect-page permission/profile flow."""
57
- lower = (msg or "").lower()
58
- return (
59
- "devtoolsactiveport not found" in lower
60
- or "enable chrome://inspect" in lower
61
- or "not live yet" in lower
62
- or (
63
- "ws handshake failed" in lower
64
- and (
65
- "403" in lower
66
- or "opening handshake" in lower
67
- or "timed out" in lower
68
- or "timeout" in lower
69
- )
70
- )
71
- )
72
-
73
-
74
- def _is_local_chrome_mode(env=None):
75
- """True when the daemon discovers a local Chrome instead of a remote CDP WS."""
76
- return not (env or {}).get("BU_CDP_WS") and not os.environ.get("BU_CDP_WS")
77
-
78
-
79
- def daemon_alive(name=None):
80
- try:
81
- c = ipc.connect(name or NAME, timeout=1.0); c.close(); return True
82
- except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError):
83
- return False
84
-
85
-
86
- def _daemon_endpoint_names():
87
- # BH_TMP_DIR isolates one daemon per dir → no filename-prefix discovery,
88
- # just check whether our local endpoint exists. Without BH_TMP_DIR, _TMP
89
- # is the shared default (`/tmp` etc.) and we glob `bu-*.<suffix>` to find
90
- # every daemon on the machine.
91
- suffix = ".port" if ipc.IS_WINDOWS else ".sock"
92
- if ipc.BH_TMP_DIR:
93
- return [NAME] if (ipc._TMP / f"bu{suffix}").exists() else []
94
- names = []
95
- for p in sorted(ipc._TMP.glob(f"bu-*{suffix}")):
96
- raw = p.name[3:-len(suffix)]
97
- try:
98
- ipc._check(raw)
99
- except ValueError:
100
- continue
101
- names.append(raw)
102
- return names
103
-
104
-
105
- def _daemon_browser_connection(name):
106
- c = None
107
- try:
108
- c = ipc.connect(name, timeout=1.0)
109
- c.sendall(b'{"meta":"connection_status"}\n')
110
- data = b""
111
- while not data.endswith(b"\n"):
112
- chunk = c.recv(1 << 16)
113
- if not chunk:
114
- break
115
- data += chunk
116
- response = json.loads(data)
117
- if "error" in response:
118
- return None
119
- page = response.get("page")
120
- if page:
121
- page = {"title": page.get("title") or "(untitled)", "url": page.get("url") or ""}
122
- return {"name": name, "page": page}
123
- except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError, KeyError, ValueError, json.JSONDecodeError):
124
- return None
125
- finally:
126
- if c:
127
- c.close()
128
-
129
-
130
- def browser_connections():
131
- """Live browser-harness daemons with healthy CDP browser connections and their attached page."""
132
- out = []
133
- for name in _daemon_endpoint_names():
134
- conn = _daemon_browser_connection(name)
135
- if conn:
136
- out.append(conn)
137
- return out
138
-
139
-
140
- def active_browser_connections():
141
- """Count live browser-harness daemons with a healthy CDP browser connection."""
142
- return len(browser_connections())
143
-
144
-
145
- def _doctor_short_text(value, limit=None):
146
- limit = limit or DOCTOR_TEXT_LIMIT
147
- value = str(value)
148
- return value if len(value) <= limit else value[:limit - 3] + "..."
149
-
150
-
151
- def ensure_daemon(wait=60.0, name=None, env=None, _open_inspect=True):
152
- """Idempotent. Self-heals stale daemon, cold Chrome, and missing Allow on chrome://inspect."""
153
- if daemon_alive(name):
154
- # Stale daemons accept connects AND reply to meta:* (pure Python) even when the
155
- # CDP WS to Chrome is dead — probe with a real CDP call and require "result".
156
- # Must go through ipc.connect so this works on Windows (TCP loopback) too;
157
- # raw AF_UNIX here would fail on every warm call and churn the daemon.
158
- try:
159
- s = ipc.connect(name or NAME, timeout=3.0)
160
- s.sendall(b'{"method":"Target.getTargets","params":{}}\n')
161
- data = b""
162
- while not data.endswith(b"\n"):
163
- chunk = s.recv(1 << 16)
164
- if not chunk: break
165
- data += chunk
166
- if b'"result"' in data: return
167
- except Exception: pass
168
- restart_daemon(name)
169
-
170
- import subprocess, sys
171
- local = _is_local_chrome_mode(env)
172
- for attempt in (0, 1):
173
- e = {**os.environ, **({"BU_NAME": name} if name else {}), **(env or {})}
174
- p = subprocess.Popen(
175
- [sys.executable, "-m", "browser_harness.daemon"],
176
- env=e, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **ipc.spawn_kwargs(),
177
- )
178
- deadline = time.time() + wait
179
- while time.time() < deadline:
180
- if daemon_alive(name): return
181
- if p.poll() is not None: break
182
- time.sleep(0.2)
183
- msg = _log_tail(name) or ""
184
- if local and attempt == 0 and _needs_chrome_remote_debugging_prompt(msg):
185
- if _open_inspect:
186
- _open_chrome_inspect()
187
- print("browser-harness: click Allow on chrome://inspect (and tick the checkbox if shown)", file=sys.stderr)
188
- restart_daemon(name)
189
- continue
190
- raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check {ipc.log_path(name or NAME)}")
191
-
192
-
193
- def stop_remote_daemon(name="remote"):
194
- """Stop a remote daemon and its backing Browser Use cloud browser.
195
-
196
- Triggers the daemon's clean shutdown, which PATCHes
197
- /browsers/{id} {"action":"stop"} so billing ends and any profile
198
- state in the session is persisted."""
199
- # restart_daemon is misnamed — it only stops the daemon (sends
200
- # shutdown, SIGTERMs if needed, unlinks socket+pid). It never
201
- # restarts anything on its own; a follow-up `browser-harness`
202
- # call would auto-spawn a fresh one via ensure_daemon(). That
203
- # "run-it-again-to-restart" workflow is why it was named that way.
204
- restart_daemon(name)
205
-
206
-
207
- def restart_daemon(name=None):
208
- """Best-effort daemon shutdown + socket/pid cleanup.
209
-
210
- Name is historical: callers typically follow this with another
211
- `browser-harness` invocation, which auto-spawns a fresh daemon via
212
- ensure_daemon(). The function itself only stops."""
213
- import signal
214
-
215
- pid_path = str(ipc.pid_path(name or NAME))
216
- try:
217
- c = ipc.connect(name or NAME, timeout=5.0)
218
- c.sendall(b'{"meta":"shutdown"}\n')
219
- c.recv(1024)
220
- c.close()
221
- except Exception:
222
- pass
223
- try:
224
- pid = int(open(pid_path).read())
225
- except (FileNotFoundError, ValueError):
226
- pid = None
227
- if pid:
228
- for _ in range(75):
229
- try:
230
- os.kill(pid, 0)
231
- time.sleep(0.2)
232
- except (ProcessLookupError, OSError, SystemError):
233
- break
234
- else:
235
- try:
236
- os.kill(pid, signal.SIGTERM)
237
- except (ProcessLookupError, OSError, SystemError):
238
- pass
239
- ipc.cleanup_endpoint(name or NAME)
240
- try:
241
- os.unlink(pid_path)
242
- except FileNotFoundError:
243
- pass
244
-
245
-
246
- def _browser_use(path, method, body=None):
247
- key = os.environ.get("BROWSER_USE_API_KEY")
248
- if not key:
249
- raise RuntimeError("BROWSER_USE_API_KEY missing -- see .env.example")
250
- req = urllib.request.Request(
251
- f"{BU_API}{path}",
252
- method=method,
253
- data=(json.dumps(body).encode() if body is not None else None),
254
- headers={"X-Browser-Use-API-Key": key, "Content-Type": "application/json"},
255
- )
256
- return json.loads(urllib.request.urlopen(req, timeout=60).read() or b"{}")
257
-
258
-
259
- def _stop_cloud_browser(browser_id):
260
- if not browser_id:
261
- return
262
- try:
263
- _browser_use(f"/browsers/{browser_id}", "PATCH", {"action": "stop"})
264
- except BaseException:
265
- pass
266
-
267
-
268
- def _cdp_ws_from_url(cdp_url):
269
- return json.loads(urllib.request.urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"]
270
-
271
-
272
- def _has_local_gui():
273
- """True when this machine plausibly has a browser we can open. False on headless servers."""
274
- import platform
275
- system = platform.system()
276
- if system in ("Darwin", "Windows"):
277
- return True
278
- if system == "Linux":
279
- return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
280
- return False
281
-
282
-
283
- def _show_live_url(url):
284
- """Print liveUrl and auto-open it locally if there's a GUI."""
285
- import sys, webbrowser
286
- if not url: return
287
- print(url)
288
- if not _has_local_gui():
289
- print("(no local GUI — share the liveUrl with the user)", file=sys.stderr)
290
- return
291
- try:
292
- webbrowser.open(url, new=2)
293
- print("(opened liveUrl in your default browser)", file=sys.stderr)
294
- except Exception as e:
295
- print(f"(couldn't auto-open: {e} — share the liveUrl with the user)", file=sys.stderr)
296
-
297
-
298
- def list_cloud_profiles():
299
- """List cloud profiles under the current API key.
300
-
301
- Returns [{id, name, userId, cookieDomains, lastUsedAt}, ...]. `cookieDomains`
302
- is the array of domain strings the cloud profile has cookies for — use
303
- `len(cookieDomains)` as a cheap 'how much is logged in' summary. Per-cookie
304
- detail on a *local* profile before sync: `profile-use inspect --profile <name>`.
305
-
306
- Paginates through all pages — the API caps `pageSize` at 100."""
307
- out, page = [], 1
308
- while True:
309
- listing = _browser_use(f"/profiles?pageSize=100&pageNumber={page}", "GET")
310
- items = listing.get("items") if isinstance(listing, dict) else listing
311
- if not items:
312
- break
313
- for p in items:
314
- detail = _browser_use(f"/profiles/{p['id']}", "GET")
315
- out.append({
316
- "id": detail["id"],
317
- "name": detail.get("name"),
318
- "userId": detail.get("userId"),
319
- "cookieDomains": detail.get("cookieDomains") or [],
320
- "lastUsedAt": detail.get("lastUsedAt"),
321
- })
322
- if isinstance(listing, dict) and len(out) >= listing.get("totalItems", len(out)):
323
- break
324
- page += 1
325
- return out
326
-
327
-
328
- def _resolve_profile_name(profile_name):
329
- """Find a single cloud profile by exact name; raise if 0 or >1 match."""
330
- matches = [p for p in list_cloud_profiles() if p.get("name") == profile_name]
331
- if not matches:
332
- raise RuntimeError(f"no cloud profile named {profile_name!r} -- call list_cloud_profiles() or sync_local_profile() first")
333
- if len(matches) > 1:
334
- raise RuntimeError(f"{len(matches)} cloud profiles named {profile_name!r} -- pass profileId=<uuid> instead")
335
- return matches[0]["id"]
336
-
337
-
338
- def start_remote_daemon(name="remote", profileName=None, **create_kwargs):
339
- """Provision a Browser Use cloud browser and start a daemon attached to it.
340
-
341
- kwargs forwarded to `POST /browsers` (camelCase):
342
- profileId — cloud profile UUID; start already-logged-in. Default: none (clean browser).
343
- profileName — cloud profile name; resolved client-side to profileId via list_cloud_profiles().
344
- proxyCountryCode — ISO2 country code (default "us"); pass None to disable the BU proxy.
345
- timeout — minutes, 1..240.
346
- customProxy — {host, port, username, password, ignoreCertErrors}.
347
- browserScreenWidth / browserScreenHeight, allowResizing, enableRecording.
348
-
349
- Returns the full browser dict including `liveUrl`. Prints the liveUrl and
350
- auto-opens it locally when a GUI is detected, so the user can watch along."""
351
- if daemon_alive(name):
352
- raise RuntimeError(f"daemon {name!r} already alive -- restart_daemon({name!r}) first")
353
- if profileName:
354
- if "profileId" in create_kwargs:
355
- raise RuntimeError("pass profileName OR profileId, not both")
356
- create_kwargs["profileId"] = _resolve_profile_name(profileName)
357
- browser = _browser_use("/browsers", "POST", create_kwargs)
358
- try:
359
- ensure_daemon(
360
- name=name,
361
- env={"BU_CDP_WS": _cdp_ws_from_url(browser["cdpUrl"]), "BU_BROWSER_ID": browser["id"]},
362
- )
363
- except BaseException:
364
- _stop_cloud_browser(browser.get("id"))
365
- raise
366
- _show_live_url(browser.get("liveUrl"))
367
- return browser
368
-
369
-
370
- def list_local_profiles():
371
- """Detected local browser profiles on this machine. Shells out to `profile-use list --json`.
372
- Returns [{BrowserName, BrowserPath, ProfileName, ProfilePath, DisplayName}, ...].
373
- Requires `profile-use` (see interaction-skills/profile-sync.md for install)."""
374
- import json, shutil, subprocess
375
- if not shutil.which("profile-use"):
376
- raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
377
- return json.loads(subprocess.check_output(["profile-use", "list", "--json"], text=True))
378
-
379
-
380
- def sync_local_profile(profile_name, browser=None, cloud_profile_id=None,
381
- include_domains=None, exclude_domains=None):
382
- """Sync a local profile's cookies to a cloud profile. Returns the cloud UUID.
383
-
384
- Shells out to `profile-use sync` (v1.0.4+). Requires BROWSER_USE_API_KEY and the
385
- target local Chrome profile to be closed (profile-use needs an exclusive lock on
386
- the Cookies DB).
387
-
388
- Args:
389
- profile_name: local Chrome profile name (as shown by `list_local_profiles`).
390
- browser: disambiguate when multiple browsers have profiles of the
391
- same name (e.g. "Google Chrome"). Default: any match.
392
- cloud_profile_id: push cookies into this existing cloud profile instead of
393
- creating a new one. Idempotent — call again to refresh
394
- the same profile. Default: create new.
395
- include_domains: only sync cookies for these domains (and subdomains).
396
- Leading dot is optional. Example: ["google.com", "stripe.com"].
397
- exclude_domains: drop cookies for these domains (and subdomains). Applied
398
- before `include_domains` so exclude wins on overlap."""
399
- import os, re, shutil, subprocess, sys
400
- if not shutil.which("profile-use"):
401
- raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
402
- if not os.environ.get("BROWSER_USE_API_KEY"):
403
- raise RuntimeError("BROWSER_USE_API_KEY missing")
404
- cmd = ["profile-use", "sync", "--profile", profile_name]
405
- if browser:
406
- cmd += ["--browser", browser]
407
- if cloud_profile_id:
408
- cmd += ["--cloud-profile-id", cloud_profile_id]
409
- for d in include_domains or []:
410
- cmd += ["--domain", d]
411
- for d in exclude_domains or []:
412
- cmd += ["--exclude-domain", d]
413
- r = subprocess.run(cmd, text=True, capture_output=True)
414
- sys.stdout.write(r.stdout)
415
- sys.stderr.write(r.stderr)
416
- if r.returncode != 0:
417
- raise RuntimeError(f"profile-use sync failed (exit {r.returncode})")
418
- # With --cloud-profile-id the tool prints "♻️ Using existing cloud profile"
419
- # instead of "Profile created: <uuid>", so we already know the UUID.
420
- if cloud_profile_id:
421
- return cloud_profile_id
422
- m = re.search(r"Profile created:\s+([0-9a-f-]{36})", r.stdout)
423
- if not m:
424
- raise RuntimeError(f"profile-use did not report a profile UUID (exit {r.returncode})")
425
- return m.group(1)
426
-
427
-
428
- def _version():
429
- """Installed version of the browser-harness package. Empty string if unknown."""
430
- try:
431
- from importlib.metadata import PackageNotFoundError, version
432
- try:
433
- return version("browser-harness")
434
- except PackageNotFoundError:
435
- return ""
436
- except Exception:
437
- return ""
438
-
439
-
440
- def _repo_dir():
441
- """Return the repo root if this install is an editable git clone, else None."""
442
- for p in Path(__file__).resolve().parents:
443
- if (p / ".git").is_dir():
444
- return p
445
- return None
446
-
447
-
448
- def _install_mode():
449
- """"git" for editable clone, "pypi" for an installed wheel, "unknown" otherwise."""
450
- if _repo_dir():
451
- return "git"
452
- return "pypi" if _version() else "unknown"
453
-
454
-
455
- def _cache_read():
456
- try:
457
- return json.loads(VERSION_CACHE.read_text())
458
- except (FileNotFoundError, ValueError):
459
- return {}
460
-
461
-
462
- def _cache_write(data):
463
- try:
464
- VERSION_CACHE.write_text(json.dumps(data))
465
- except OSError:
466
- pass
467
-
468
-
469
- def _latest_release_tag(force=False):
470
- """Return latest release tag from GitHub, or None. Cached for 24h to avoid hammering the API."""
471
- cache = _cache_read()
472
- now = time.time()
473
- if not force and cache.get("tag") and now - cache.get("fetched_at", 0) < VERSION_CACHE_TTL:
474
- return cache["tag"]
475
- try:
476
- req = urllib.request.Request(GH_RELEASES, headers={"Accept": "application/vnd.github+json"})
477
- tag = json.loads(urllib.request.urlopen(req, timeout=5).read()).get("tag_name") or ""
478
- except Exception:
479
- return cache.get("tag") # fall back to last known
480
- tag = tag.lstrip("v")
481
- _cache_write({**cache, "tag": tag, "fetched_at": now})
482
- return tag or None
483
-
484
-
485
- def _version_tuple(v):
486
- """Best-effort semver parse. Non-numeric components sort as 0, so pre-releases may not rank perfectly."""
487
- parts = []
488
- for s in (v or "").split("."):
489
- m = ""
490
- for ch in s:
491
- if ch.isdigit():
492
- m += ch
493
- else:
494
- break
495
- parts.append(int(m) if m else 0)
496
- return tuple(parts)
497
-
498
-
499
- def check_for_update():
500
- """(current, latest, newer_available). latest may be None if the API was unreachable and no cache exists."""
501
- cur = _version()
502
- latest = _latest_release_tag()
503
- newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
504
- return cur, latest, newer
505
-
506
-
507
- def print_update_banner(out=None):
508
- """Print the update banner to stderr once per day. Silent when up-to-date or offline."""
509
- import sys
510
- out = out or sys.stderr
511
- cache = _cache_read()
512
- today = time.strftime("%Y-%m-%d")
513
- if cache.get("banner_shown_on") == today:
514
- return
515
- cur, latest, newer = check_for_update()
516
- if not newer:
517
- return
518
- print(f"[browser-harness] update available: {cur} -> {latest}", file=out)
519
- print(f"[browser-harness] agents: run `browser-harness --update -y` to upgrade and restart the daemon", file=out)
520
- _cache_write({**cache, "banner_shown_on": today})
521
-
522
-
523
- def _chrome_running():
524
- """Cross-platform best-effort check for a running Chrome/Edge process."""
525
- import platform, subprocess
526
- system = platform.system()
527
- try:
528
- if system == "Windows":
529
- out = subprocess.check_output(["tasklist"], text=True, timeout=5)
530
- names = ("chrome.exe", "msedge.exe")
531
- else:
532
- out = subprocess.check_output(["ps", "-A", "-o", "comm="], text=True, timeout=5)
533
- names = ("Google Chrome", "chrome", "chromium", "Microsoft Edge", "msedge")
534
- return any(n.lower() in out.lower() for n in names)
535
- except Exception:
536
- return False
537
-
538
-
539
- def _open_chrome_inspect():
540
- """Open chrome://inspect/#remote-debugging so the user can tick the checkbox."""
541
- import platform, subprocess, webbrowser
542
- url = "chrome://inspect/#remote-debugging"
543
- if platform.system() == "Darwin":
544
- try:
545
- subprocess.run([
546
- "osascript",
547
- "-e", 'tell application "Google Chrome" to activate',
548
- "-e", f'tell application "Google Chrome" to open location "{url}"',
549
- ], timeout=5, check=False)
550
- return
551
- except Exception:
552
- pass
553
- try:
554
- webbrowser.open(url, new=2)
555
- except Exception:
556
- pass
557
-
558
-
559
- def run_setup():
560
- """Interactive bootstrap: attach to the running browser, guiding the user through chrome://inspect if needed.
561
-
562
- Exit code 0 on success, 1 on failure."""
563
- import sys
564
- print("browser-harness setup: attaching to your browser...")
565
-
566
- if daemon_alive():
567
- print("daemon already running; nothing to do.")
568
- return 0
569
-
570
- if not _chrome_running():
571
- print("no Chrome/Edge process detected. please start your browser and rerun `browser-harness --setup`.")
572
- return 1
573
-
574
- # First attach attempt.
575
- try:
576
- ensure_daemon(wait=20.0)
577
- print("daemon is up.")
578
- return 0
579
- except RuntimeError as e:
580
- first_err = str(e)
581
-
582
- needs_inspect = _is_local_chrome_mode() and _needs_chrome_remote_debugging_prompt(first_err)
583
- if needs_inspect:
584
- print("chrome remote-debugging is not enabled on the current profile.")
585
- print("opening chrome://inspect/#remote-debugging -- in the tab that opens:")
586
- print(" 1. if chrome shows the profile picker, pick your normal profile;")
587
- print(" 2. tick 'Discover network targets' and click Allow if prompted.")
588
- _open_chrome_inspect()
589
- else:
590
- print(f"attach failed: {first_err}")
591
- print("retrying for up to 60s (chrome may still be starting up)...")
592
-
593
- deadline = time.time() + 60
594
- last = first_err
595
- while time.time() < deadline:
596
- try:
597
- ensure_daemon(wait=5.0, _open_inspect=False)
598
- print("daemon is up.")
599
- return 0
600
- except RuntimeError as e:
601
- last = str(e)
602
- time.sleep(2)
603
-
604
- print(f"setup failed: {last}", file=sys.stderr)
605
- print("run `browser-harness --doctor` for diagnostics.", file=sys.stderr)
606
- return 1
607
-
608
-
609
- def run_doctor():
610
- """Read-only diagnostics. Exit 0 iff everything looks healthy."""
611
- import platform, shutil, sys
612
- cur = _version()
613
- mode = _install_mode()
614
- chrome = _chrome_running()
615
- daemon = daemon_alive()
616
- connections = browser_connections()
617
- profile_use = shutil.which("profile-use") is not None
618
- api_key = bool(os.environ.get("BROWSER_USE_API_KEY"))
619
- latest = _latest_release_tag()
620
- # Only claim an update when we know the installed version — `cur or "(unknown)"`
621
- # for display would otherwise be parsed as (0,) and flag every latest as newer.
622
- newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
623
- cur_display = cur or "(unknown)"
624
-
625
- def row(label, ok, detail=""):
626
- mark = "ok " if ok else "FAIL"
627
- print(f" [{mark}] {label}{(' — ' + detail) if detail else ''}")
628
-
629
- print("browser-harness doctor")
630
- print(f" platform {platform.system()} {platform.release()}")
631
- print(f" python {sys.version.split()[0]}")
632
- print(f" version {cur_display} ({mode})")
633
- if latest:
634
- print(f" latest release {latest}" + (" (update available)" if newer else ""))
635
- else:
636
- print(" latest release (could not reach github)")
637
- row("chrome running", chrome, "" if chrome else "start chrome/edge and rerun `browser-harness --setup`")
638
- row("daemon alive", daemon, "" if daemon else "run `browser-harness --setup` to attach")
639
- row("active browser connections", bool(connections), str(len(connections)))
640
- for conn in connections:
641
- page = conn.get("page")
642
- if page:
643
- title = _doctor_short_text(page["title"])
644
- url = _doctor_short_text(page["url"])
645
- print(f" {conn['name']} — active page: {title} — {url}")
646
- else:
647
- print(f" {conn['name']} — active page: (no real page)")
648
- row("profile-use installed", profile_use, "" if profile_use else "optional: curl -fsSL https://browser-use.com/profile.sh | sh")
649
- row("BROWSER_USE_API_KEY set", api_key, "" if api_key else "optional: needed only for cloud browsers / profile sync")
650
- # Core health = chrome + daemon. Profile-use/api-key are optional.
651
- return 0 if (chrome and daemon) else 1
652
-
653
-
654
- def _prompt_yes(question, default_yes=True, yes=False):
655
- if yes:
656
- return True
657
- suffix = "[Y/n]" if default_yes else "[y/N]"
658
- try:
659
- ans = input(f"{question} {suffix} ").strip().lower()
660
- except EOFError:
661
- return default_yes
662
- if not ans:
663
- return default_yes
664
- return ans.startswith("y")
665
-
666
-
667
- def run_update(yes=False):
668
- """Pull the latest version and (after prompt) restart the daemon so it picks up changed code.
669
-
670
- Exit 0 on success, non-zero on failure."""
671
- import subprocess, sys
672
- cur, latest, newer = check_for_update()
673
- # Only short-circuit as "up to date" when we actually know the installed
674
- # version. Otherwise `newer=False` just means "couldn't compare" — proceed.
675
- if cur and latest and not newer:
676
- print(f"browser-harness is up to date ({cur}).")
677
- return 0
678
- if cur and latest:
679
- print(f"updating browser-harness: {cur} -> {latest}")
680
- elif latest:
681
- print(f"installed version unknown; will try to update to {latest}.")
682
- else:
683
- print("could not reach github; will try to update anyway.")
684
-
685
- mode = _install_mode()
686
- if mode == "git":
687
- repo = _repo_dir()
688
- status = subprocess.run(["git", "-C", str(repo), "status", "--porcelain"], capture_output=True, text=True)
689
- if status.returncode != 0:
690
- print(f"git status failed: {status.stderr.strip()}", file=sys.stderr)
691
- return 1
692
- if status.stdout.strip():
693
- print(f"refusing to update: uncommitted changes in {repo}", file=sys.stderr)
694
- print("commit or stash them first, or run `git -C %s pull` yourself." % repo, file=sys.stderr)
695
- return 1
696
- r = subprocess.run(["git", "-C", str(repo), "pull", "--ff-only"])
697
- if r.returncode != 0:
698
- return r.returncode
699
- elif mode == "pypi":
700
- tool_upgrade = subprocess.run(["uv", "tool", "upgrade", "browser-harness"])
701
- if tool_upgrade.returncode != 0:
702
- # Fall back to pip in case this wasn't a `uv tool install`.
703
- pip = subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", "browser-harness"])
704
- if pip.returncode != 0:
705
- return pip.returncode
706
- else:
707
- print("unknown install mode; can't auto-update.", file=sys.stderr)
708
- return 1
709
-
710
- # Invalidate banner/tag cache so the new version doesn't keep nagging.
711
- cache = _cache_read()
712
- cache.pop("banner_shown_on", None)
713
- _cache_write(cache)
714
-
715
- if daemon_alive():
716
- if _prompt_yes("restart the running daemon so it picks up the new code?", default_yes=True, yes=yes):
717
- restart_daemon()
718
- print("daemon stopped; it will auto-restart on next `browser-harness` call.")
719
- else:
720
- print("daemon left running on old code. run `browser-harness` and it'll use the new code after the daemon recycles.")
721
- print("update complete.")
722
- return 0
1
+ """Browser Harness admin commands and remote browser lifecycle.
2
+
3
+ [WHO]: Provides daemon setup/doctor/reload/update helpers and Browser Use cloud browser/profile functions
4
+ [FROM]: Depends on browser_harness._ipc, urllib, subprocess, socket, tempfile, pathlib, and environment variables
5
+ [TO]: Consumed by browser_harness.run and user snippets through pre-imported admin helpers
6
+ [HERE]: extensions/builtin/browser/src/browser_harness/admin.py within vendored Browser Harness package
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import socket
12
+ import tempfile
13
+ import time
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ from . import _ipc as ipc
18
+
19
+
20
+ def _load_env():
21
+ repo_root = Path(__file__).resolve().parents[2]
22
+ workspace = Path(os.environ.get("BH_AGENT_WORKSPACE", repo_root / "agent-workspace")).expanduser()
23
+ for p in (repo_root / ".env", workspace / ".env"):
24
+ if not p.exists():
25
+ continue
26
+ _load_env_file(p)
27
+
28
+
29
+ def _load_env_file(p):
30
+ for line in p.read_text().splitlines():
31
+ line = line.strip()
32
+ if not line or line.startswith("#") or "=" not in line:
33
+ continue
34
+ k, v = line.split("=", 1)
35
+ os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
36
+
37
+
38
+ _load_env()
39
+
40
+ NAME = os.environ.get("BU_NAME", "default")
41
+ BU_API = "https://api.browser-use.com/api/v3"
42
+ GH_RELEASES = "https://api.github.com/repos/browser-use/browser-harness/releases/latest"
43
+ VERSION_CACHE = Path(tempfile.gettempdir()) / "bu-version-cache.json"
44
+ VERSION_CACHE_TTL = 24 * 3600
45
+ DOCTOR_TEXT_LIMIT = 140
46
+
47
+
48
+ def _log_tail(name):
49
+ try:
50
+ return ipc.log_path(name or NAME).read_text().strip().splitlines()[-1]
51
+ except (FileNotFoundError, IndexError):
52
+ return None
53
+
54
+
55
+ def _needs_chrome_remote_debugging_prompt(msg):
56
+ """True when Chrome needs the inspect-page permission/profile flow."""
57
+ lower = (msg or "").lower()
58
+ return (
59
+ "devtoolsactiveport not found" in lower
60
+ or "enable chrome://inspect" in lower
61
+ or "not live yet" in lower
62
+ or (
63
+ "ws handshake failed" in lower
64
+ and (
65
+ "403" in lower
66
+ or "opening handshake" in lower
67
+ or "timed out" in lower
68
+ or "timeout" in lower
69
+ )
70
+ )
71
+ )
72
+
73
+
74
+ def _is_local_chrome_mode(env=None):
75
+ """True when the daemon discovers a local Chrome instead of a remote CDP WS."""
76
+ return not (env or {}).get("BU_CDP_WS") and not os.environ.get("BU_CDP_WS")
77
+
78
+
79
+ def daemon_alive(name=None):
80
+ try:
81
+ c = ipc.connect(name or NAME, timeout=1.0); c.close(); return True
82
+ except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError):
83
+ return False
84
+
85
+
86
+ def _daemon_endpoint_names():
87
+ # BH_TMP_DIR isolates one daemon per dir → no filename-prefix discovery,
88
+ # just check whether our local endpoint exists. Without BH_TMP_DIR, _TMP
89
+ # is the shared default (`/tmp` etc.) and we glob `bu-*.<suffix>` to find
90
+ # every daemon on the machine.
91
+ suffix = ".port" if ipc.IS_WINDOWS else ".sock"
92
+ if ipc.BH_TMP_DIR:
93
+ return [NAME] if (ipc._TMP / f"bu{suffix}").exists() else []
94
+ names = []
95
+ for p in sorted(ipc._TMP.glob(f"bu-*{suffix}")):
96
+ raw = p.name[3:-len(suffix)]
97
+ try:
98
+ ipc._check(raw)
99
+ except ValueError:
100
+ continue
101
+ names.append(raw)
102
+ return names
103
+
104
+
105
+ def _daemon_browser_connection(name):
106
+ c = None
107
+ try:
108
+ c = ipc.connect(name, timeout=1.0)
109
+ c.sendall(b'{"meta":"connection_status"}\n')
110
+ data = b""
111
+ while not data.endswith(b"\n"):
112
+ chunk = c.recv(1 << 16)
113
+ if not chunk:
114
+ break
115
+ data += chunk
116
+ response = json.loads(data)
117
+ if "error" in response:
118
+ return None
119
+ page = response.get("page")
120
+ if page:
121
+ page = {"title": page.get("title") or "(untitled)", "url": page.get("url") or ""}
122
+ return {"name": name, "page": page}
123
+ except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError, KeyError, ValueError, json.JSONDecodeError):
124
+ return None
125
+ finally:
126
+ if c:
127
+ c.close()
128
+
129
+
130
+ def browser_connections():
131
+ """Live browser-harness daemons with healthy CDP browser connections and their attached page."""
132
+ out = []
133
+ for name in _daemon_endpoint_names():
134
+ conn = _daemon_browser_connection(name)
135
+ if conn:
136
+ out.append(conn)
137
+ return out
138
+
139
+
140
+ def active_browser_connections():
141
+ """Count live browser-harness daemons with a healthy CDP browser connection."""
142
+ return len(browser_connections())
143
+
144
+
145
+ def _doctor_short_text(value, limit=None):
146
+ limit = limit or DOCTOR_TEXT_LIMIT
147
+ value = str(value)
148
+ return value if len(value) <= limit else value[:limit - 3] + "..."
149
+
150
+
151
+ def ensure_daemon(wait=60.0, name=None, env=None, _open_inspect=True):
152
+ """Idempotent. Self-heals stale daemon, cold Chrome, and missing Allow on chrome://inspect."""
153
+ if daemon_alive(name):
154
+ # Stale daemons accept connects AND reply to meta:* (pure Python) even when the
155
+ # CDP WS to Chrome is dead — probe with a real CDP call and require "result".
156
+ # Must go through ipc.connect so this works on Windows (TCP loopback) too;
157
+ # raw AF_UNIX here would fail on every warm call and churn the daemon.
158
+ try:
159
+ s = ipc.connect(name or NAME, timeout=3.0)
160
+ s.sendall(b'{"method":"Target.getTargets","params":{}}\n')
161
+ data = b""
162
+ while not data.endswith(b"\n"):
163
+ chunk = s.recv(1 << 16)
164
+ if not chunk: break
165
+ data += chunk
166
+ if b'"result"' in data: return
167
+ except Exception: pass
168
+ restart_daemon(name)
169
+
170
+ import subprocess, sys
171
+ local = _is_local_chrome_mode(env)
172
+ for attempt in (0, 1):
173
+ e = {**os.environ, **({"BU_NAME": name} if name else {}), **(env or {})}
174
+ p = subprocess.Popen(
175
+ [sys.executable, "-m", "browser_harness.daemon"],
176
+ env=e, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **ipc.spawn_kwargs(),
177
+ )
178
+ deadline = time.time() + wait
179
+ while time.time() < deadline:
180
+ if daemon_alive(name): return
181
+ if p.poll() is not None: break
182
+ time.sleep(0.2)
183
+ msg = _log_tail(name) or ""
184
+ if local and attempt == 0 and _needs_chrome_remote_debugging_prompt(msg):
185
+ if _open_inspect:
186
+ _open_chrome_inspect()
187
+ print("browser-harness: click Allow on chrome://inspect (and tick the checkbox if shown)", file=sys.stderr)
188
+ restart_daemon(name)
189
+ continue
190
+ raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check {ipc.log_path(name or NAME)}")
191
+
192
+
193
+ def stop_remote_daemon(name="remote"):
194
+ """Stop a remote daemon and its backing Browser Use cloud browser.
195
+
196
+ Triggers the daemon's clean shutdown, which PATCHes
197
+ /browsers/{id} {"action":"stop"} so billing ends and any profile
198
+ state in the session is persisted."""
199
+ # restart_daemon is misnamed — it only stops the daemon (sends
200
+ # shutdown, SIGTERMs if needed, unlinks socket+pid). It never
201
+ # restarts anything on its own; a follow-up `browser-harness`
202
+ # call would auto-spawn a fresh one via ensure_daemon(). That
203
+ # "run-it-again-to-restart" workflow is why it was named that way.
204
+ restart_daemon(name)
205
+
206
+
207
+ def restart_daemon(name=None):
208
+ """Best-effort daemon shutdown + socket/pid cleanup.
209
+
210
+ Name is historical: callers typically follow this with another
211
+ `browser-harness` invocation, which auto-spawns a fresh daemon via
212
+ ensure_daemon(). The function itself only stops."""
213
+ import signal
214
+
215
+ pid_path = str(ipc.pid_path(name or NAME))
216
+ try:
217
+ c = ipc.connect(name or NAME, timeout=5.0)
218
+ c.sendall(b'{"meta":"shutdown"}\n')
219
+ c.recv(1024)
220
+ c.close()
221
+ except Exception:
222
+ pass
223
+ try:
224
+ pid = int(open(pid_path).read())
225
+ except (FileNotFoundError, ValueError):
226
+ pid = None
227
+ if pid:
228
+ for _ in range(75):
229
+ try:
230
+ os.kill(pid, 0)
231
+ time.sleep(0.2)
232
+ except (ProcessLookupError, OSError, SystemError):
233
+ break
234
+ else:
235
+ try:
236
+ os.kill(pid, signal.SIGTERM)
237
+ except (ProcessLookupError, OSError, SystemError):
238
+ pass
239
+ ipc.cleanup_endpoint(name or NAME)
240
+ try:
241
+ os.unlink(pid_path)
242
+ except FileNotFoundError:
243
+ pass
244
+
245
+
246
+ def _browser_use(path, method, body=None):
247
+ key = os.environ.get("BROWSER_USE_API_KEY")
248
+ if not key:
249
+ raise RuntimeError("BROWSER_USE_API_KEY missing -- see .env.example")
250
+ req = urllib.request.Request(
251
+ f"{BU_API}{path}",
252
+ method=method,
253
+ data=(json.dumps(body).encode() if body is not None else None),
254
+ headers={"X-Browser-Use-API-Key": key, "Content-Type": "application/json"},
255
+ )
256
+ return json.loads(urllib.request.urlopen(req, timeout=60).read() or b"{}")
257
+
258
+
259
+ def _stop_cloud_browser(browser_id):
260
+ if not browser_id:
261
+ return
262
+ try:
263
+ _browser_use(f"/browsers/{browser_id}", "PATCH", {"action": "stop"})
264
+ except BaseException:
265
+ pass
266
+
267
+
268
+ def _cdp_ws_from_url(cdp_url):
269
+ return json.loads(urllib.request.urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"]
270
+
271
+
272
+ def _has_local_gui():
273
+ """True when this machine plausibly has a browser we can open. False on headless servers."""
274
+ import platform
275
+ system = platform.system()
276
+ if system in ("Darwin", "Windows"):
277
+ return True
278
+ if system == "Linux":
279
+ return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
280
+ return False
281
+
282
+
283
+ def _show_live_url(url):
284
+ """Print liveUrl and auto-open it locally if there's a GUI."""
285
+ import sys, webbrowser
286
+ if not url: return
287
+ print(url)
288
+ if not _has_local_gui():
289
+ print("(no local GUI — share the liveUrl with the user)", file=sys.stderr)
290
+ return
291
+ try:
292
+ webbrowser.open(url, new=2)
293
+ print("(opened liveUrl in your default browser)", file=sys.stderr)
294
+ except Exception as e:
295
+ print(f"(couldn't auto-open: {e} — share the liveUrl with the user)", file=sys.stderr)
296
+
297
+
298
+ def list_cloud_profiles():
299
+ """List cloud profiles under the current API key.
300
+
301
+ Returns [{id, name, userId, cookieDomains, lastUsedAt}, ...]. `cookieDomains`
302
+ is the array of domain strings the cloud profile has cookies for — use
303
+ `len(cookieDomains)` as a cheap 'how much is logged in' summary. Per-cookie
304
+ detail on a *local* profile before sync: `profile-use inspect --profile <name>`.
305
+
306
+ Paginates through all pages — the API caps `pageSize` at 100."""
307
+ out, page = [], 1
308
+ while True:
309
+ listing = _browser_use(f"/profiles?pageSize=100&pageNumber={page}", "GET")
310
+ items = listing.get("items") if isinstance(listing, dict) else listing
311
+ if not items:
312
+ break
313
+ for p in items:
314
+ detail = _browser_use(f"/profiles/{p['id']}", "GET")
315
+ out.append({
316
+ "id": detail["id"],
317
+ "name": detail.get("name"),
318
+ "userId": detail.get("userId"),
319
+ "cookieDomains": detail.get("cookieDomains") or [],
320
+ "lastUsedAt": detail.get("lastUsedAt"),
321
+ })
322
+ if isinstance(listing, dict) and len(out) >= listing.get("totalItems", len(out)):
323
+ break
324
+ page += 1
325
+ return out
326
+
327
+
328
+ def _resolve_profile_name(profile_name):
329
+ """Find a single cloud profile by exact name; raise if 0 or >1 match."""
330
+ matches = [p for p in list_cloud_profiles() if p.get("name") == profile_name]
331
+ if not matches:
332
+ raise RuntimeError(f"no cloud profile named {profile_name!r} -- call list_cloud_profiles() or sync_local_profile() first")
333
+ if len(matches) > 1:
334
+ raise RuntimeError(f"{len(matches)} cloud profiles named {profile_name!r} -- pass profileId=<uuid> instead")
335
+ return matches[0]["id"]
336
+
337
+
338
+ def start_remote_daemon(name="remote", profileName=None, **create_kwargs):
339
+ """Provision a Browser Use cloud browser and start a daemon attached to it.
340
+
341
+ kwargs forwarded to `POST /browsers` (camelCase):
342
+ profileId — cloud profile UUID; start already-logged-in. Default: none (clean browser).
343
+ profileName — cloud profile name; resolved client-side to profileId via list_cloud_profiles().
344
+ proxyCountryCode — ISO2 country code (default "us"); pass None to disable the BU proxy.
345
+ timeout — minutes, 1..240.
346
+ customProxy — {host, port, username, password, ignoreCertErrors}.
347
+ browserScreenWidth / browserScreenHeight, allowResizing, enableRecording.
348
+
349
+ Returns the full browser dict including `liveUrl`. Prints the liveUrl and
350
+ auto-opens it locally when a GUI is detected, so the user can watch along."""
351
+ if daemon_alive(name):
352
+ raise RuntimeError(f"daemon {name!r} already alive -- restart_daemon({name!r}) first")
353
+ if profileName:
354
+ if "profileId" in create_kwargs:
355
+ raise RuntimeError("pass profileName OR profileId, not both")
356
+ create_kwargs["profileId"] = _resolve_profile_name(profileName)
357
+ browser = _browser_use("/browsers", "POST", create_kwargs)
358
+ try:
359
+ ensure_daemon(
360
+ name=name,
361
+ env={"BU_CDP_WS": _cdp_ws_from_url(browser["cdpUrl"]), "BU_BROWSER_ID": browser["id"]},
362
+ )
363
+ except BaseException:
364
+ _stop_cloud_browser(browser.get("id"))
365
+ raise
366
+ _show_live_url(browser.get("liveUrl"))
367
+ return browser
368
+
369
+
370
+ def list_local_profiles():
371
+ """Detected local browser profiles on this machine. Shells out to `profile-use list --json`.
372
+ Returns [{BrowserName, BrowserPath, ProfileName, ProfilePath, DisplayName}, ...].
373
+ Requires `profile-use` (see interaction-skills/profile-sync.md for install)."""
374
+ import json, shutil, subprocess
375
+ if not shutil.which("profile-use"):
376
+ raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
377
+ return json.loads(subprocess.check_output(["profile-use", "list", "--json"], text=True))
378
+
379
+
380
+ def sync_local_profile(profile_name, browser=None, cloud_profile_id=None,
381
+ include_domains=None, exclude_domains=None):
382
+ """Sync a local profile's cookies to a cloud profile. Returns the cloud UUID.
383
+
384
+ Shells out to `profile-use sync` (v1.0.4+). Requires BROWSER_USE_API_KEY and the
385
+ target local Chrome profile to be closed (profile-use needs an exclusive lock on
386
+ the Cookies DB).
387
+
388
+ Args:
389
+ profile_name: local Chrome profile name (as shown by `list_local_profiles`).
390
+ browser: disambiguate when multiple browsers have profiles of the
391
+ same name (e.g. "Google Chrome"). Default: any match.
392
+ cloud_profile_id: push cookies into this existing cloud profile instead of
393
+ creating a new one. Idempotent — call again to refresh
394
+ the same profile. Default: create new.
395
+ include_domains: only sync cookies for these domains (and subdomains).
396
+ Leading dot is optional. Example: ["google.com", "stripe.com"].
397
+ exclude_domains: drop cookies for these domains (and subdomains). Applied
398
+ before `include_domains` so exclude wins on overlap."""
399
+ import os, re, shutil, subprocess, sys
400
+ if not shutil.which("profile-use"):
401
+ raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
402
+ if not os.environ.get("BROWSER_USE_API_KEY"):
403
+ raise RuntimeError("BROWSER_USE_API_KEY missing")
404
+ cmd = ["profile-use", "sync", "--profile", profile_name]
405
+ if browser:
406
+ cmd += ["--browser", browser]
407
+ if cloud_profile_id:
408
+ cmd += ["--cloud-profile-id", cloud_profile_id]
409
+ for d in include_domains or []:
410
+ cmd += ["--domain", d]
411
+ for d in exclude_domains or []:
412
+ cmd += ["--exclude-domain", d]
413
+ r = subprocess.run(cmd, text=True, capture_output=True)
414
+ sys.stdout.write(r.stdout)
415
+ sys.stderr.write(r.stderr)
416
+ if r.returncode != 0:
417
+ raise RuntimeError(f"profile-use sync failed (exit {r.returncode})")
418
+ # With --cloud-profile-id the tool prints "♻️ Using existing cloud profile"
419
+ # instead of "Profile created: <uuid>", so we already know the UUID.
420
+ if cloud_profile_id:
421
+ return cloud_profile_id
422
+ m = re.search(r"Profile created:\s+([0-9a-f-]{36})", r.stdout)
423
+ if not m:
424
+ raise RuntimeError(f"profile-use did not report a profile UUID (exit {r.returncode})")
425
+ return m.group(1)
426
+
427
+
428
+ def _version():
429
+ """Installed version of the browser-harness package. Empty string if unknown."""
430
+ try:
431
+ from importlib.metadata import PackageNotFoundError, version
432
+ try:
433
+ return version("browser-harness")
434
+ except PackageNotFoundError:
435
+ return ""
436
+ except Exception:
437
+ return ""
438
+
439
+
440
+ def _repo_dir():
441
+ """Return the repo root if this install is an editable git clone, else None."""
442
+ for p in Path(__file__).resolve().parents:
443
+ if (p / ".git").is_dir():
444
+ return p
445
+ return None
446
+
447
+
448
+ def _install_mode():
449
+ """"git" for editable clone, "pypi" for an installed wheel, "unknown" otherwise."""
450
+ if _repo_dir():
451
+ return "git"
452
+ return "pypi" if _version() else "unknown"
453
+
454
+
455
+ def _cache_read():
456
+ try:
457
+ return json.loads(VERSION_CACHE.read_text())
458
+ except (FileNotFoundError, ValueError):
459
+ return {}
460
+
461
+
462
+ def _cache_write(data):
463
+ try:
464
+ VERSION_CACHE.write_text(json.dumps(data))
465
+ except OSError:
466
+ pass
467
+
468
+
469
+ def _latest_release_tag(force=False):
470
+ """Return latest release tag from GitHub, or None. Cached for 24h to avoid hammering the API."""
471
+ cache = _cache_read()
472
+ now = time.time()
473
+ if not force and cache.get("tag") and now - cache.get("fetched_at", 0) < VERSION_CACHE_TTL:
474
+ return cache["tag"]
475
+ try:
476
+ req = urllib.request.Request(GH_RELEASES, headers={"Accept": "application/vnd.github+json"})
477
+ tag = json.loads(urllib.request.urlopen(req, timeout=5).read()).get("tag_name") or ""
478
+ except Exception:
479
+ return cache.get("tag") # fall back to last known
480
+ tag = tag.lstrip("v")
481
+ _cache_write({**cache, "tag": tag, "fetched_at": now})
482
+ return tag or None
483
+
484
+
485
+ def _version_tuple(v):
486
+ """Best-effort semver parse. Non-numeric components sort as 0, so pre-releases may not rank perfectly."""
487
+ parts = []
488
+ for s in (v or "").split("."):
489
+ m = ""
490
+ for ch in s:
491
+ if ch.isdigit():
492
+ m += ch
493
+ else:
494
+ break
495
+ parts.append(int(m) if m else 0)
496
+ return tuple(parts)
497
+
498
+
499
+ def check_for_update():
500
+ """(current, latest, newer_available). latest may be None if the API was unreachable and no cache exists."""
501
+ cur = _version()
502
+ latest = _latest_release_tag()
503
+ newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
504
+ return cur, latest, newer
505
+
506
+
507
+ def print_update_banner(out=None):
508
+ """Print the update banner to stderr once per day. Silent when up-to-date or offline."""
509
+ import sys
510
+ out = out or sys.stderr
511
+ cache = _cache_read()
512
+ today = time.strftime("%Y-%m-%d")
513
+ if cache.get("banner_shown_on") == today:
514
+ return
515
+ cur, latest, newer = check_for_update()
516
+ if not newer:
517
+ return
518
+ print(f"[browser-harness] update available: {cur} -> {latest}", file=out)
519
+ print(f"[browser-harness] agents: run `browser-harness --update -y` to upgrade and restart the daemon", file=out)
520
+ _cache_write({**cache, "banner_shown_on": today})
521
+
522
+
523
+ def _chrome_running():
524
+ """Cross-platform best-effort check for a running Chrome/Edge process."""
525
+ import platform, subprocess
526
+ system = platform.system()
527
+ try:
528
+ if system == "Windows":
529
+ out = subprocess.check_output(["tasklist"], text=True, timeout=5)
530
+ names = ("chrome.exe", "msedge.exe")
531
+ else:
532
+ out = subprocess.check_output(["ps", "-A", "-o", "comm="], text=True, timeout=5)
533
+ names = ("Google Chrome", "chrome", "chromium", "Microsoft Edge", "msedge")
534
+ return any(n.lower() in out.lower() for n in names)
535
+ except Exception:
536
+ return False
537
+
538
+
539
+ def _open_chrome_inspect():
540
+ """Open chrome://inspect/#remote-debugging so the user can tick the checkbox."""
541
+ import platform, subprocess, webbrowser
542
+ url = "chrome://inspect/#remote-debugging"
543
+ if platform.system() == "Darwin":
544
+ try:
545
+ subprocess.run([
546
+ "osascript",
547
+ "-e", 'tell application "Google Chrome" to activate',
548
+ "-e", f'tell application "Google Chrome" to open location "{url}"',
549
+ ], timeout=5, check=False)
550
+ return
551
+ except Exception:
552
+ pass
553
+ try:
554
+ webbrowser.open(url, new=2)
555
+ except Exception:
556
+ pass
557
+
558
+
559
+ def run_setup():
560
+ """Interactive bootstrap: attach to the running browser, guiding the user through chrome://inspect if needed.
561
+
562
+ Exit code 0 on success, 1 on failure."""
563
+ import sys
564
+ print("browser-harness setup: attaching to your browser...")
565
+
566
+ if daemon_alive():
567
+ print("daemon already running; nothing to do.")
568
+ return 0
569
+
570
+ if not _chrome_running():
571
+ print("no Chrome/Edge process detected. please start your browser and rerun `browser-harness --setup`.")
572
+ return 1
573
+
574
+ # First attach attempt.
575
+ try:
576
+ ensure_daemon(wait=20.0)
577
+ print("daemon is up.")
578
+ return 0
579
+ except RuntimeError as e:
580
+ first_err = str(e)
581
+
582
+ needs_inspect = _is_local_chrome_mode() and _needs_chrome_remote_debugging_prompt(first_err)
583
+ if needs_inspect:
584
+ print("chrome remote-debugging is not enabled on the current profile.")
585
+ print("opening chrome://inspect/#remote-debugging -- in the tab that opens:")
586
+ print(" 1. if chrome shows the profile picker, pick your normal profile;")
587
+ print(" 2. tick 'Discover network targets' and click Allow if prompted.")
588
+ _open_chrome_inspect()
589
+ else:
590
+ print(f"attach failed: {first_err}")
591
+ print("retrying for up to 60s (chrome may still be starting up)...")
592
+
593
+ deadline = time.time() + 60
594
+ last = first_err
595
+ while time.time() < deadline:
596
+ try:
597
+ ensure_daemon(wait=5.0, _open_inspect=False)
598
+ print("daemon is up.")
599
+ return 0
600
+ except RuntimeError as e:
601
+ last = str(e)
602
+ time.sleep(2)
603
+
604
+ print(f"setup failed: {last}", file=sys.stderr)
605
+ print("run `browser-harness --doctor` for diagnostics.", file=sys.stderr)
606
+ return 1
607
+
608
+
609
+ def run_doctor():
610
+ """Read-only diagnostics. Exit 0 iff everything looks healthy."""
611
+ import platform, shutil, sys
612
+ cur = _version()
613
+ mode = _install_mode()
614
+ chrome = _chrome_running()
615
+ daemon = daemon_alive()
616
+ connections = browser_connections()
617
+ profile_use = shutil.which("profile-use") is not None
618
+ api_key = bool(os.environ.get("BROWSER_USE_API_KEY"))
619
+ latest = _latest_release_tag()
620
+ # Only claim an update when we know the installed version — `cur or "(unknown)"`
621
+ # for display would otherwise be parsed as (0,) and flag every latest as newer.
622
+ newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
623
+ cur_display = cur or "(unknown)"
624
+
625
+ def row(label, ok, detail=""):
626
+ mark = "ok " if ok else "FAIL"
627
+ print(f" [{mark}] {label}{(' — ' + detail) if detail else ''}")
628
+
629
+ print("browser-harness doctor")
630
+ print(f" platform {platform.system()} {platform.release()}")
631
+ print(f" python {sys.version.split()[0]}")
632
+ print(f" version {cur_display} ({mode})")
633
+ if latest:
634
+ print(f" latest release {latest}" + (" (update available)" if newer else ""))
635
+ else:
636
+ print(" latest release (could not reach github)")
637
+ row("chrome running", chrome, "" if chrome else "start chrome/edge and rerun `browser-harness --setup`")
638
+ row("daemon alive", daemon, "" if daemon else "run `browser-harness --setup` to attach")
639
+ row("active browser connections", bool(connections), str(len(connections)))
640
+ for conn in connections:
641
+ page = conn.get("page")
642
+ if page:
643
+ title = _doctor_short_text(page["title"])
644
+ url = _doctor_short_text(page["url"])
645
+ print(f" {conn['name']} — active page: {title} — {url}")
646
+ else:
647
+ print(f" {conn['name']} — active page: (no real page)")
648
+ row("profile-use installed", profile_use, "" if profile_use else "optional: curl -fsSL https://browser-use.com/profile.sh | sh")
649
+ row("BROWSER_USE_API_KEY set", api_key, "" if api_key else "optional: needed only for cloud browsers / profile sync")
650
+ # Core health = chrome + daemon. Profile-use/api-key are optional.
651
+ return 0 if (chrome and daemon) else 1
652
+
653
+
654
+ def _prompt_yes(question, default_yes=True, yes=False):
655
+ if yes:
656
+ return True
657
+ suffix = "[Y/n]" if default_yes else "[y/N]"
658
+ try:
659
+ ans = input(f"{question} {suffix} ").strip().lower()
660
+ except EOFError:
661
+ return default_yes
662
+ if not ans:
663
+ return default_yes
664
+ return ans.startswith("y")
665
+
666
+
667
+ def run_update(yes=False):
668
+ """Pull the latest version and (after prompt) restart the daemon so it picks up changed code.
669
+
670
+ Exit 0 on success, non-zero on failure."""
671
+ import subprocess, sys
672
+ cur, latest, newer = check_for_update()
673
+ # Only short-circuit as "up to date" when we actually know the installed
674
+ # version. Otherwise `newer=False` just means "couldn't compare" — proceed.
675
+ if cur and latest and not newer:
676
+ print(f"browser-harness is up to date ({cur}).")
677
+ return 0
678
+ if cur and latest:
679
+ print(f"updating browser-harness: {cur} -> {latest}")
680
+ elif latest:
681
+ print(f"installed version unknown; will try to update to {latest}.")
682
+ else:
683
+ print("could not reach github; will try to update anyway.")
684
+
685
+ mode = _install_mode()
686
+ if mode == "git":
687
+ repo = _repo_dir()
688
+ status = subprocess.run(["git", "-C", str(repo), "status", "--porcelain"], capture_output=True, text=True)
689
+ if status.returncode != 0:
690
+ print(f"git status failed: {status.stderr.strip()}", file=sys.stderr)
691
+ return 1
692
+ if status.stdout.strip():
693
+ print(f"refusing to update: uncommitted changes in {repo}", file=sys.stderr)
694
+ print("commit or stash them first, or run `git -C %s pull` yourself." % repo, file=sys.stderr)
695
+ return 1
696
+ r = subprocess.run(["git", "-C", str(repo), "pull", "--ff-only"])
697
+ if r.returncode != 0:
698
+ return r.returncode
699
+ elif mode == "pypi":
700
+ tool_upgrade = subprocess.run(["uv", "tool", "upgrade", "browser-harness"])
701
+ if tool_upgrade.returncode != 0:
702
+ # Fall back to pip in case this wasn't a `uv tool install`.
703
+ pip = subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", "browser-harness"])
704
+ if pip.returncode != 0:
705
+ return pip.returncode
706
+ else:
707
+ print("unknown install mode; can't auto-update.", file=sys.stderr)
708
+ return 1
709
+
710
+ # Invalidate banner/tag cache so the new version doesn't keep nagging.
711
+ cache = _cache_read()
712
+ cache.pop("banner_shown_on", None)
713
+ _cache_write(cache)
714
+
715
+ if daemon_alive():
716
+ if _prompt_yes("restart the running daemon so it picks up the new code?", default_yes=True, yes=yes):
717
+ restart_daemon()
718
+ print("daemon stopped; it will auto-restart on next `browser-harness` call.")
719
+ else:
720
+ print("daemon left running on old code. run `browser-harness` and it'll use the new code after the daemon recycles.")
721
+ print("update complete.")
722
+ return 0