@m13v/s4l 1.6.197-rc.7

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 (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1314 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +497 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,968 @@
1
+ #!/usr/bin/env python3
2
+ # Run via:
3
+ # uv run --quiet --with mcp <this-file>
4
+ # or:
5
+ # python3 -m pip install --break-system-packages 'mcp>=1.0.0' && python3 <this-file>
6
+ """
7
+ browser-harness MCP server.
8
+
9
+ Wraps the `browser-harness` CLI (https://github.com/browser-use/browser-harness)
10
+ behind an MCP stdio server so any Claude Code session can drive direct CDP
11
+ browser control without manually managing the daemon.
12
+
13
+ Architecture:
14
+ - Auto-launches a dedicated Chrome instance on port 9555 with a persistent
15
+ profile at ~/.claude/browser-profiles/browser-harness so cookies/sessions
16
+ carry across Claude Code sessions.
17
+ - Exposes tools that shell out to the `browser-harness` CLI with
18
+ BU_CDP_URL pointed at our managed Chrome.
19
+ - Stays out of the user's normal Chrome (which is what playwright-extension
20
+ uses); this is a separate isolated profile.
21
+
22
+ Cross-platform: works on macOS and Linux. Chrome binary is auto-detected
23
+ (env override: BH_CHROME_BIN). On Linux + root we add --no-sandbox; on Linux
24
+ without a display we add --headless=new (override with BH_HEADLESS=0 to force
25
+ headed, e.g. when Xvfb is available).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import json
32
+ import os
33
+ import shutil
34
+ import socket
35
+ import subprocess
36
+ import sys
37
+ import time
38
+ import urllib.request
39
+ import urllib.error
40
+ from pathlib import Path
41
+
42
+ from mcp.server.fastmcp import FastMCP
43
+
44
+ # --- Config ---
45
+
46
+ PORT = int(os.environ.get("BH_PORT", "9555"))
47
+
48
+ # Profile name can be overridden via BH_PROFILE_NAME env so multiple harness
49
+ # instances (twitter-harness on 9555, linkedin-harness on 9556, reddit-harness
50
+ # on 9557) can run side by side on SEPARATE persistent profiles + PID files
51
+ # without stomping each other's cookies/sessions. If this is hardcoded to
52
+ # "browser-harness", every non-default-port instance lands on the Twitter
53
+ # profile and shares one PID_FILE, so the per-instance ensure_chrome() calls
54
+ # SIGKILL each other's Chrome (regression 2026-06-02, fixed by restoring this).
55
+ # Default "browser-harness" keeps the existing Twitter setup unchanged.
56
+ PROFILE_NAME = os.environ.get("BH_PROFILE_NAME", "browser-harness")
57
+ PROFILE_DIR = Path.home() / ".claude" / "browser-profiles" / PROFILE_NAME
58
+ PID_FILE = Path.home() / ".claude" / "browser-profiles" / f"{PROFILE_NAME}.chrome.pid"
59
+ LOG_FILE = Path.home() / ".claude" / "browser-profiles" / f"{PROFILE_NAME}.chrome.log"
60
+ MCP_LOG_FILE = Path.home() / ".claude" / "browser-profiles" / f"{PROFILE_NAME}.mcp.log"
61
+
62
+
63
+ def _playwright_chromium_bins() -> list[str]:
64
+ """Chromium binaries the runtime's `playwright install chromium` step drops
65
+ into the shared ms-playwright cache, newest revision first.
66
+
67
+ A .mcpb user with no system Chrome (e.g. only Arc installed) still gets a
68
+ working browser here because the runtime download lands in this cache. The
69
+ rev number (chromium-1208/1217/1223/...) changes per Playwright pin, and the
70
+ bundle is named "Google Chrome for Testing" on recent revs / "Chromium" on
71
+ older ones, so we glob rather than hardcode. macOS uses chrome-mac[-arm64];
72
+ Linux uses chrome-linux.
73
+ """
74
+ cache = Path.home() / "Library" / "Caches" / "ms-playwright" # macOS
75
+ linux_cache = Path.home() / ".cache" / "ms-playwright" # Linux
76
+ patterns = [
77
+ # macOS, newer revs (arm64 + x64): "Google Chrome for Testing"
78
+ "chromium-*/chrome-mac*/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
79
+ # macOS, older revs: "Chromium"
80
+ "chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium",
81
+ # Linux
82
+ "chromium-*/chrome-linux/chrome",
83
+ ]
84
+ found: list[str] = []
85
+ for root in (cache, linux_cache):
86
+ if not root.exists():
87
+ continue
88
+ for pat in patterns:
89
+ for hit in root.glob(pat):
90
+ if hit.exists():
91
+ found.append(str(hit))
92
+
93
+ def _rev(p: str) -> int:
94
+ # Sort by the chromium-<rev> number so the newest install wins.
95
+ for part in Path(p).parts:
96
+ if part.startswith("chromium-") and part[len("chromium-"):].isdigit():
97
+ return int(part[len("chromium-"):])
98
+ return 0
99
+
100
+ return sorted(set(found), key=_rev, reverse=True)
101
+
102
+
103
+ def _detect_chrome_bin() -> str:
104
+ """Find the Chrome binary on disk. Env override wins. Returns "" if nothing
105
+ real is found (callers can then trigger an install fallback)."""
106
+ env = os.environ.get("BH_CHROME_BIN")
107
+ if env and Path(env).exists():
108
+ return env
109
+
110
+ candidates = [
111
+ # macOS
112
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
113
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
114
+ # Linux (Debian/Ubuntu defaults)
115
+ "/usr/bin/google-chrome",
116
+ "/usr/bin/google-chrome-stable",
117
+ "/usr/bin/chromium",
118
+ "/usr/bin/chromium-browser",
119
+ "/snap/bin/chromium",
120
+ ]
121
+ for p in candidates:
122
+ if Path(p).exists():
123
+ return p
124
+
125
+ # The runtime's own bundled Chromium (ms-playwright cache). This is what
126
+ # makes setup work on a machine with no system Chrome installed.
127
+ for p in _playwright_chromium_bins():
128
+ return p
129
+
130
+ # Fall back to PATH lookup.
131
+ for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
132
+ found = shutil.which(name)
133
+ if found:
134
+ return found
135
+
136
+ return ""
137
+
138
+
139
+ def _chrome_exists(p: str) -> bool:
140
+ return bool(p) and (Path(p).exists() or shutil.which(p) is not None)
141
+
142
+
143
+ def _venv_python() -> str | None:
144
+ """The runtime venv interpreter (has playwright). Mirrors runtime.ts:
145
+ <SAPS_STATE_DIR|~/.social-autoposter-mcp>/runtime/.venv/bin/python3."""
146
+ state_dir = Path(os.environ.get("SAPS_STATE_DIR", str(Path.home() / ".social-autoposter-mcp")))
147
+ if sys.platform == "win32":
148
+ cand = state_dir / "runtime" / ".venv" / "Scripts" / "python.exe"
149
+ else:
150
+ cand = state_dir / "runtime" / ".venv" / "bin" / "python3"
151
+ return str(cand) if cand.exists() else None
152
+
153
+
154
+ def _install_chromium() -> dict:
155
+ """Last-ditch fallback when no Chromium is found anywhere: run
156
+ `playwright install chromium` so a fresh machine self-heals instead of
157
+ dead-ending at no_chrome_binary. Uses the runtime venv if present, otherwise
158
+ the interpreter running this server. The download lands in the shared
159
+ ms-playwright cache that _detect_chrome_bin() globs."""
160
+ py = _venv_python() or sys.executable
161
+ _log(f"no chromium found; attempting `playwright install chromium` via {py}")
162
+ try:
163
+ r = subprocess.run(
164
+ [py, "-m", "playwright", "install", "chromium"],
165
+ capture_output=True,
166
+ text=True,
167
+ timeout=int(os.environ.get("BH_CHROME_INSTALL_TIMEOUT_SEC", "600")),
168
+ )
169
+ except Exception as e: # noqa: BLE001
170
+ _log(f"chromium install failed to launch: {e}")
171
+ return {"ok": False, "python": py, "error": str(e)}
172
+ ok = r.returncode == 0
173
+ _log(f"chromium install exit={r.returncode}")
174
+ return {"ok": ok, "python": py, "exit": r.returncode, "out": (r.stdout + r.stderr)[-600:]}
175
+
176
+
177
+ def _detect_browser_harness_bin() -> str:
178
+ """Find the browser-harness CLI. Env override wins."""
179
+ env = os.environ.get("BH_HARNESS_BIN")
180
+ if env and Path(env).exists():
181
+ return env
182
+
183
+ # uv-tool default install location.
184
+ candidate = Path.home() / ".local" / "bin" / "browser-harness"
185
+ if candidate.exists():
186
+ return str(candidate)
187
+
188
+ found = shutil.which("browser-harness")
189
+ if found:
190
+ return found
191
+
192
+ return str(candidate) # report the expected path even if missing
193
+
194
+
195
+ CHROME_BIN = _detect_chrome_bin()
196
+ BROWSER_HARNESS_BIN = _detect_browser_harness_bin()
197
+ CDP_URL = f"http://127.0.0.1:{PORT}"
198
+
199
+ # Default exec timeout for a single tool call (seconds). Browser flows can be
200
+ # slow; raise the cap so multi-step scripts don't get killed mid-flight.
201
+ EXEC_TIMEOUT_SEC = int(os.environ.get("BH_EXEC_TIMEOUT_SEC", "300"))
202
+
203
+ # Heuristic: are we on Linux without a graphical display? Then we should
204
+ # launch Chrome headless unless the operator explicitly says otherwise.
205
+ _IS_LINUX = sys.platform.startswith("linux")
206
+ _HAS_DISPLAY = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
207
+ _DEFAULT_HEADLESS = "1" if (_IS_LINUX and not _HAS_DISPLAY) else "0"
208
+ HEADLESS = os.environ.get("BH_HEADLESS", _DEFAULT_HEADLESS) == "1"
209
+ RUNNING_AS_ROOT = (hasattr(os, "geteuid") and os.geteuid() == 0)
210
+
211
+
212
+ # --- Logging ---
213
+
214
+ def _log(msg: str) -> None:
215
+ try:
216
+ MCP_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
217
+ with MCP_LOG_FILE.open("a") as f:
218
+ f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
219
+ except Exception:
220
+ pass
221
+
222
+ # --- Chrome lifecycle ---
223
+
224
+ # Hardcoded fallbacks used only the very first time a profile launches (before
225
+ # Chrome has written any window_placement to its Preferences).
226
+ DEFAULT_WINDOW_POS = "3042,-1032"
227
+ DEFAULT_WINDOW_SIZE = "1024,1013"
228
+
229
+
230
+ def _persisted_window_geometry() -> tuple[str | None, str | None]:
231
+ """Read the window position+size Chrome last persisted for THIS profile.
232
+
233
+ Chrome writes the live window bounds to <profile>/Default/Preferences ->
234
+ browser.window_placement (left/top/right/bottom, in screen coords) whenever
235
+ the user moves/resizes the window. By reading that back and feeding it into
236
+ the launch flags, a user's manual placement survives SIGKILL+relaunch
237
+ instead of snapping back to the hardcoded default. Returns ("X,Y", "W,H")
238
+ or (None, None) when nothing usable is persisted yet.
239
+ """
240
+ pref = PROFILE_DIR / "Default" / "Preferences"
241
+ try:
242
+ wp = json.loads(pref.read_text()).get("browser", {}).get("window_placement")
243
+ except (FileNotFoundError, ValueError, OSError):
244
+ return (None, None)
245
+ if not isinstance(wp, dict) or wp.get("maximized"):
246
+ return (None, None)
247
+ try:
248
+ left, top = int(wp["left"]), int(wp["top"])
249
+ width, height = int(wp["right"]) - left, int(wp["bottom"]) - top
250
+ except (KeyError, TypeError, ValueError):
251
+ return (None, None)
252
+ if width <= 0 or height <= 0:
253
+ return (f"{left},{top}", None)
254
+ return (f"{left},{top}", f"{width},{height}")
255
+
256
+
257
+ def _port_open(port: int) -> bool:
258
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
259
+ s.settimeout(0.5)
260
+ try:
261
+ s.connect(("127.0.0.1", port))
262
+ return True
263
+ except OSError:
264
+ return False
265
+ finally:
266
+ s.close()
267
+
268
+
269
+ def _cdp_alive() -> bool:
270
+ """Return True if the CDP /json/version endpoint responds."""
271
+ if not _port_open(PORT):
272
+ return False
273
+ try:
274
+ with urllib.request.urlopen(f"{CDP_URL}/json/version", timeout=1.5) as r:
275
+ return r.status == 200
276
+ except (urllib.error.URLError, TimeoutError, OSError):
277
+ return False
278
+
279
+
280
+ def _read_pid() -> int | None:
281
+ try:
282
+ return int(PID_FILE.read_text().strip())
283
+ except (FileNotFoundError, ValueError):
284
+ return None
285
+
286
+
287
+ def _pid_alive(pid: int) -> bool:
288
+ try:
289
+ os.kill(pid, 0)
290
+ return True
291
+ except OSError:
292
+ return False
293
+
294
+
295
+ def _build_chrome_cmd() -> list[str]:
296
+ """Compose the Chrome launch argv for this platform / environment."""
297
+ cmd = [
298
+ CHROME_BIN,
299
+ f"--remote-debugging-port={PORT}",
300
+ f"--user-data-dir={PROFILE_DIR}",
301
+ "--no-first-run",
302
+ "--no-default-browser-check",
303
+ "--disable-features=ChromeWhatsNewUI",
304
+ ]
305
+
306
+ # Headless + sandboxing for Linux/root.
307
+ if HEADLESS:
308
+ cmd.append("--headless=new")
309
+ cmd.append("--disable-gpu")
310
+ if RUNNING_AS_ROOT or _IS_LINUX:
311
+ # --no-sandbox is required when Chrome runs as root (e.g. inside a
312
+ # rootful container/VM). Harmless on Linux non-root too.
313
+ cmd.append("--no-sandbox")
314
+ cmd.append("--disable-dev-shm-usage")
315
+
316
+ # Persistent window placement on macOS multi-monitor setups.
317
+ # Skip on headless / Linux where positioning is meaningless and the
318
+ # off-screen values would just hide the window on a single-monitor setup.
319
+ # Position priority (2026-06-02):
320
+ # 1. BH_WINDOW_POS / BH_WINDOW_SIZE env (explicit hard override)
321
+ # 2. whatever Chrome last persisted for this profile (the user's own
322
+ # manually-dragged position) -> so user placement survives relaunch
323
+ # 3. hardcoded default (first-ever launch only)
324
+ # We still pass an explicit --window-position flag (rather than letting
325
+ # Chrome restore on its own), so SIGKILL+relaunch can't cascade/drift the
326
+ # window: we control the exact value, but that value now tracks the user's
327
+ # last placement instead of a fixed constant.
328
+ if not HEADLESS and not _IS_LINUX:
329
+ saved_pos, saved_size = _persisted_window_geometry()
330
+ win_pos = os.environ.get("BH_WINDOW_POS") or saved_pos or DEFAULT_WINDOW_POS
331
+ win_size = os.environ.get("BH_WINDOW_SIZE") or saved_size or DEFAULT_WINDOW_SIZE
332
+ cmd.append(f"--window-position={win_pos}")
333
+ cmd.append(f"--window-size={win_size}")
334
+
335
+ # Open a REAL http(s) page (NOT about:blank) so the harness daemon's
336
+ # attach_first_page() finds an existing real tab and reuses it. Upstream
337
+ # browser-harness creates a throwaway about:blank on every re-attach where
338
+ # is_real_page() is false (which about:blank is); launching blank feeds that
339
+ # loop, piling up orphan tabs that cleanup_harness_tabs then has to sweep.
340
+ # Landing URL is derived from the profile's platform; BH_LAUNCH_URL overrides.
341
+ cmd.append(_launch_url())
342
+ return cmd
343
+
344
+
345
+ def _launch_url() -> str:
346
+ """Initial tab URL for the managed Chrome. MUST be a real http(s) page, not
347
+ about:blank, so the browser-harness daemon reuses it instead of spawning
348
+ throwaway blank tabs (see _build_chrome_cmd). Derived from PROFILE_NAME;
349
+ BH_LAUNCH_URL overrides for non-standard profiles."""
350
+ override = os.environ.get("BH_LAUNCH_URL", "").strip()
351
+ if override:
352
+ return override
353
+ name = PROFILE_NAME.lower()
354
+ if "linkedin" in name:
355
+ return "https://www.linkedin.com/feed/"
356
+ if "reddit" in name:
357
+ return "https://www.reddit.com/"
358
+ return "https://x.com"
359
+
360
+
361
+ def ensure_chrome() -> dict:
362
+ """Make sure our managed Chrome is running on PORT. Idempotent."""
363
+ global CHROME_BIN
364
+ if _cdp_alive():
365
+ return {"status": "already_running", "pid": _read_pid(), "cdp": CDP_URL}
366
+
367
+ if not _chrome_exists(CHROME_BIN):
368
+ # Re-detect (the runtime may have finished its chromium download since
369
+ # this server booted), then auto-install as a last resort so a fresh
370
+ # machine self-heals instead of dead-ending here.
371
+ CHROME_BIN = _detect_chrome_bin()
372
+ if not _chrome_exists(CHROME_BIN):
373
+ install = _install_chromium()
374
+ CHROME_BIN = _detect_chrome_bin()
375
+ if not _chrome_exists(CHROME_BIN):
376
+ return {
377
+ "status": "no_chrome_binary",
378
+ "looked_for": CHROME_BIN or "(none found)",
379
+ "install_attempt": install,
380
+ "hint": "Auto-install of chromium failed. Set BH_CHROME_BIN to a Chrome/Chromium path, or run `playwright install chromium`.",
381
+ }
382
+
383
+ PROFILE_DIR.mkdir(parents=True, exist_ok=True)
384
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
385
+
386
+ # Clean up stale daemon socket so first browser-harness call doesn't
387
+ # try to talk to a dead daemon from a previous Chrome instance.
388
+ for stale in ("/tmp/bu-default.sock", "/tmp/bu-default.pid"):
389
+ try:
390
+ os.unlink(stale)
391
+ except FileNotFoundError:
392
+ pass
393
+
394
+ # If a Chrome with our profile is still partially up but not on the port,
395
+ # try to surface that in the log rather than silently double-launching.
396
+ pid = _read_pid()
397
+ if pid and _pid_alive(pid):
398
+ _log(f"stale pid {pid} alive but CDP dead; killing")
399
+ try:
400
+ os.kill(pid, 9)
401
+ except OSError:
402
+ pass
403
+ time.sleep(0.5)
404
+
405
+ cmd = _build_chrome_cmd()
406
+
407
+ log_fh = LOG_FILE.open("ab")
408
+ proc = subprocess.Popen(
409
+ cmd,
410
+ stdout=log_fh,
411
+ stderr=log_fh,
412
+ stdin=subprocess.DEVNULL,
413
+ start_new_session=True,
414
+ )
415
+ PID_FILE.write_text(str(proc.pid))
416
+ _log(f"launched Chrome pid={proc.pid} port={PORT} profile={PROFILE_DIR} headless={HEADLESS}")
417
+
418
+ # Wait for CDP to be ready. First launch on a cold/fresh machine has to
419
+ # create the profile and run Chrome's first-run setup, which routinely
420
+ # exceeds 15s on a slow VM; an over-tight deadline returns launch_timeout
421
+ # and the caller runs against a port that was about to come up. 30s is the
422
+ # safe floor (override with BH_LAUNCH_TIMEOUT_SEC).
423
+ launch_timeout = int(os.environ.get("BH_LAUNCH_TIMEOUT_SEC", "30"))
424
+ deadline = time.time() + launch_timeout
425
+ while time.time() < deadline:
426
+ if _cdp_alive():
427
+ return {"status": "started", "pid": proc.pid, "cdp": CDP_URL}
428
+ time.sleep(0.3)
429
+
430
+ return {
431
+ "status": "launch_timeout",
432
+ "pid": proc.pid,
433
+ "cdp": CDP_URL,
434
+ "log": str(LOG_FILE),
435
+ "waited_sec": launch_timeout,
436
+ "log_tail": _chrome_log_tail(),
437
+ }
438
+
439
+
440
+ def _port_owner_pids() -> list[int]:
441
+ """PIDs LISTENing on our debug PORT, via lsof. Lets stop_chrome reap a Chrome
442
+ that another launcher (e.g. setup_twitter_auth.py's connect_x) started without
443
+ writing PID_FILE, instead of stranding an un-reapable orphan that makes
444
+ bh_start keep reporting 'already_running'. Returns [] if lsof is unavailable."""
445
+ try:
446
+ out = subprocess.run(
447
+ ["lsof", "-ti", f"tcp:{PORT}", "-sTCP:LISTEN"],
448
+ capture_output=True, text=True, timeout=5,
449
+ )
450
+ except (OSError, subprocess.SubprocessError):
451
+ return []
452
+ pids = []
453
+ for tok in (out.stdout or "").split():
454
+ try:
455
+ pids.append(int(tok))
456
+ except ValueError:
457
+ pass
458
+ return pids
459
+
460
+
461
+ def _terminate(pid: int, grace: float = 5.0) -> None:
462
+ """SIGTERM, then SIGKILL only if still alive after `grace` seconds. The wait
463
+ gives Chrome time to flush its in-memory cookie store to the on-disk profile,
464
+ so a stop->start restart preserves the X session instead of coming back
465
+ logged out (the failure the setup agent hit after a hard kill)."""
466
+ try:
467
+ os.kill(pid, 15)
468
+ except OSError:
469
+ return
470
+ deadline = time.time() + grace
471
+ while time.time() < deadline:
472
+ if not _pid_alive(pid):
473
+ return
474
+ time.sleep(0.2)
475
+ try:
476
+ os.kill(pid, 9)
477
+ except OSError:
478
+ pass
479
+
480
+
481
+ def _cdp_browser_close() -> bool:
482
+ """Ask Chrome to quit via CDP Browser.close. This is Chrome's own
483
+ graceful-shutdown RPC: it tears down renderers in order and flushes the
484
+ cookie store before exiting, which signal-based termination does not
485
+ reliably guarantee. Returns True if the RPC was issued; False if the
486
+ browser-harness CLI was missing, CDP was unreachable, or the call errored.
487
+ Issuing the RPC does NOT mean Chrome has exited yet — poll the pid."""
488
+ if not shutil.which(BROWSER_HARNESS_BIN) and not Path(BROWSER_HARNESS_BIN).exists():
489
+ return False
490
+ env = os.environ.copy()
491
+ env["BU_CDP_URL"] = CDP_URL
492
+ env["PATH"] = f"{Path.home()}/.local/bin:" + env.get("PATH", "")
493
+ try:
494
+ proc = subprocess.run(
495
+ [BROWSER_HARNESS_BIN],
496
+ input="cdp('Browser.close')\n",
497
+ env=env,
498
+ capture_output=True,
499
+ text=True,
500
+ timeout=10,
501
+ )
502
+ except (subprocess.TimeoutExpired, OSError):
503
+ return False
504
+ return proc.returncode == 0
505
+
506
+
507
+ def stop_chrome() -> dict:
508
+ """Gracefully stop the managed Chrome — the tracked process AND any orphan
509
+ still LISTENing on the debug port — so connect_x-launched Chromes can't
510
+ strand the port.
511
+
512
+ Two-stage shutdown: first ask Chrome to quit itself via CDP `Browser.close`
513
+ (its own graceful-quit RPC, which flushes the cookie SQLite synchronously
514
+ before exit). If the process exits within `CDP_QUIT_DEADLINE_SEC`, we're
515
+ done — cookies are durable. Only if CDP refuses or the process doesn't
516
+ exit in time do we fall back to SIGTERM-with-grace, then SIGKILL. The old
517
+ SIGTERM+5s+SIGKILL path lost cookies because Chrome's shutdown sequence
518
+ sometimes outlasts the 5s window; CDP-first removes that race."""
519
+ CDP_QUIT_DEADLINE_SEC = 20.0
520
+ POLL_INTERVAL_SEC = 0.5
521
+
522
+ tracked_pid = _read_pid()
523
+ initial_owners = _port_owner_pids()
524
+ initial_targets: list[int] = []
525
+ if tracked_pid and _pid_alive(tracked_pid):
526
+ initial_targets.append(tracked_pid)
527
+ for owner in initial_owners:
528
+ if owner not in initial_targets and _pid_alive(owner):
529
+ initial_targets.append(owner)
530
+
531
+ cdp_attempted = False
532
+ cdp_issued = False
533
+ if initial_targets and _cdp_alive():
534
+ cdp_attempted = True
535
+ cdp_issued = _cdp_browser_close()
536
+ if cdp_issued:
537
+ deadline = time.time() + CDP_QUIT_DEADLINE_SEC
538
+ while time.time() < deadline:
539
+ still_alive = [p for p in initial_targets if _pid_alive(p)]
540
+ if not still_alive:
541
+ break
542
+ time.sleep(POLL_INTERVAL_SEC)
543
+
544
+ reaped: list[int] = []
545
+ survivors = [p for p in initial_targets if _pid_alive(p)]
546
+ for p in survivors:
547
+ _terminate(p, grace=15.0)
548
+ reaped.append(p)
549
+
550
+ for stale in (PID_FILE, Path("/tmp/bu-default.sock"), Path("/tmp/bu-default.pid")):
551
+ try:
552
+ stale.unlink()
553
+ except FileNotFoundError:
554
+ pass
555
+
556
+ via = "cdp_browser_close" if (cdp_issued and not survivors) else (
557
+ "sigterm_fallback" if cdp_attempted else "sigterm"
558
+ )
559
+ return {
560
+ "status": "stopped",
561
+ "via": via,
562
+ "tracked_pid": tracked_pid,
563
+ "initial_targets": initial_targets,
564
+ "cdp_attempted": cdp_attempted,
565
+ "cdp_issued": cdp_issued,
566
+ "sigterm_reaped": reaped,
567
+ }
568
+
569
+
570
+ # --- browser-harness exec wrapper ---
571
+
572
+ def _chrome_log_tail(lines: int = 25) -> str:
573
+ """Last `lines` of the managed-Chrome log, for surfacing in CDP errors."""
574
+ try:
575
+ text = LOG_FILE.read_text(errors="replace")
576
+ except (FileNotFoundError, OSError):
577
+ return ""
578
+ return "\n".join(text.splitlines()[-lines:])
579
+
580
+
581
+ def _ensure_cdp_ready() -> dict | None:
582
+ """Guarantee CDP is actually answering on PORT before we shell out to the
583
+ harness CLI. Returns None when CDP is live; otherwise returns a structured,
584
+ actionable error dict (and leaves the chrome log tail attached).
585
+
586
+ Without this gate, ensure_chrome() failures (no_chrome_binary,
587
+ launch_timeout) were swallowed and the CLI ran against a dead port, so the
588
+ agent saw a cryptic usage banner / connection error instead of the real
589
+ cause. This is the #1 fresh-install failure mode."""
590
+ res = ensure_chrome()
591
+ if _cdp_alive():
592
+ return None
593
+
594
+ # One self-heal attempt: a stale Chrome bound to the port but not speaking
595
+ # CDP (crashed renderer, half-dead profile) won't recover on its own.
596
+ _log(f"CDP not alive after ensure_chrome (status={res.get('status')}); attempting stop+relaunch")
597
+ stop_chrome()
598
+ time.sleep(1.0)
599
+ res = ensure_chrome()
600
+ if _cdp_alive():
601
+ return None
602
+
603
+ status = res.get("status", "unknown")
604
+ if status == "no_chrome_binary":
605
+ hint = res.get("hint", "Install Chrome/Chromium or set BH_CHROME_BIN.")
606
+ else:
607
+ hint = (
608
+ f"Chrome did not expose CDP on {CDP_URL} (status={status}). "
609
+ "On a headless Linux box ensure BH_HEADLESS=1 and a Chrome binary "
610
+ "are present; on macOS make sure no other Chrome owns the profile. "
611
+ f"See {LOG_FILE}."
612
+ )
613
+ return {
614
+ "ok": False,
615
+ "error": f"browser-harness CDP not connected: {hint}",
616
+ "cdp": CDP_URL,
617
+ "ensure_chrome": res,
618
+ "chrome_log_tail": _chrome_log_tail(),
619
+ }
620
+
621
+
622
+ def _run_harness(script: str, timeout: int = EXEC_TIMEOUT_SEC) -> dict:
623
+ if not shutil.which(BROWSER_HARNESS_BIN) and not Path(BROWSER_HARNESS_BIN).exists():
624
+ return {
625
+ "ok": False,
626
+ "error": (
627
+ f"browser-harness CLI not found at {BROWSER_HARNESS_BIN}. "
628
+ "Install with: cd ~/Developer/browser-harness && uv tool install -e ."
629
+ ),
630
+ }
631
+
632
+ cdp_err = _ensure_cdp_ready()
633
+ if cdp_err is not None:
634
+ return cdp_err
635
+
636
+ env = os.environ.copy()
637
+ env["BU_CDP_URL"] = CDP_URL
638
+ # Make sure ~/.local/bin is on PATH (uv tools live there).
639
+ env["PATH"] = f"{Path.home()}/.local/bin:" + env.get("PATH", "")
640
+
641
+ # Upstream browser-harness dropped the `-c <script>` flag and now reads the
642
+ # script from stdin only (heredoc style). Pass via stdin so we work against
643
+ # current upstream; the old `-c` form returns the usage banner and exits 1,
644
+ # which used to surface as "CDP not connected" on every fresh install.
645
+ try:
646
+ proc = subprocess.run(
647
+ [BROWSER_HARNESS_BIN],
648
+ input=script,
649
+ env=env,
650
+ capture_output=True,
651
+ text=True,
652
+ timeout=timeout,
653
+ )
654
+ except subprocess.TimeoutExpired as e:
655
+ return {
656
+ "ok": False,
657
+ "error": f"browser-harness timed out after {timeout}s",
658
+ "stdout": (e.stdout or "") if isinstance(e.stdout, str) else "",
659
+ "stderr": (e.stderr or "") if isinstance(e.stderr, str) else "",
660
+ }
661
+
662
+ return {
663
+ "ok": proc.returncode == 0,
664
+ "returncode": proc.returncode,
665
+ "stdout": proc.stdout,
666
+ "stderr": proc.stderr,
667
+ }
668
+
669
+
670
+ # --- MCP server ---
671
+
672
+ mcp = FastMCP(
673
+ "browser-harness",
674
+ instructions=(
675
+ "Direct CDP browser control via the browser-use/browser-harness CLI. "
676
+ "Runs in a dedicated Chrome with a persistent profile at "
677
+ "~/.claude/browser-profiles/browser-harness so cookies/sessions persist "
678
+ "across Claude Code sessions. Separate from the user's normal Chrome "
679
+ "(which playwright-extension drives) and from the per-platform agents "
680
+ "(reddit/twitter/linkedin/logged-in-browser/isolated-browser). "
681
+ "Primary tool: bh_run(script) — runs arbitrary Python with the harness "
682
+ "helpers pre-imported (new_tab, goto_url, page_info, capture_screenshot, "
683
+ "click_at_xy, js, type_text, press_key, fill_input, scroll, "
684
+ "wait_for_load, wait_for_element, ensure_real_tab, list_tabs, "
685
+ "switch_tab, http_get, cdp, etc.). "
686
+ "Workflow: capture_screenshot → click_at_xy(x,y) → re-screenshot. "
687
+ "Helpers cheat-sheet lives at ~/Developer/browser-harness/SKILL.md."
688
+ ),
689
+ )
690
+
691
+
692
+ @mcp.tool()
693
+ def bh_run(script: str, timeout: int = EXEC_TIMEOUT_SEC) -> str:
694
+ """Execute a Python script inside browser-harness.
695
+
696
+ The script runs with all browser-harness helpers pre-imported (new_tab,
697
+ goto_url, page_info, capture_screenshot, click_at_xy, js, type_text,
698
+ press_key, fill_input, scroll, wait_for_load, wait_for_element,
699
+ ensure_real_tab, list_tabs, switch_tab, http_get, cdp, etc.).
700
+
701
+ The first navigation in a fresh tab should be new_tab(url), not
702
+ goto_url(url) — goto runs in the user's currently-focused tab and clobbers
703
+ whatever is loaded there.
704
+
705
+ To inspect / extract data, use print(...) and read it back from the
706
+ "stdout" field of the result.
707
+
708
+ Returns a JSON string with: ok, returncode, stdout, stderr.
709
+ """
710
+ result = _run_harness(script, timeout=timeout)
711
+ return json.dumps(result, indent=2)
712
+
713
+
714
+ @mcp.tool()
715
+ def bh_status() -> str:
716
+ """Report whether the managed Chrome is alive and where it lives."""
717
+ pid = _read_pid()
718
+ owners = _port_owner_pids()
719
+ return json.dumps(
720
+ {
721
+ "cdp_url": CDP_URL,
722
+ "cdp_alive": _cdp_alive(),
723
+ "chrome_pid": pid,
724
+ "chrome_alive": (pid is not None and _pid_alive(pid)) or _cdp_alive(),
725
+ # Untracked Chromes (e.g. launched by connect_x) show up here even
726
+ # when chrome_pid is null — that's the orphan that bh_stop now reaps.
727
+ "port_owner_pids": owners,
728
+ "profile_dir": str(PROFILE_DIR),
729
+ "log_file": str(LOG_FILE),
730
+ "harness_bin": BROWSER_HARNESS_BIN,
731
+ "chrome_bin": CHROME_BIN,
732
+ "headless": HEADLESS,
733
+ "root": RUNNING_AS_ROOT,
734
+ },
735
+ indent=2,
736
+ )
737
+
738
+
739
+ @mcp.tool()
740
+ def bh_start() -> str:
741
+ """Start the managed Chrome (idempotent). Normally bh_run handles this."""
742
+ return json.dumps(ensure_chrome(), indent=2)
743
+
744
+
745
+ @mcp.tool()
746
+ def bh_stop() -> str:
747
+ """Kill the managed Chrome instance. Cookies/profile data persist on disk.
748
+
749
+ Reaps both the tracked process and any orphan still holding the debug port,
750
+ using a graceful SIGTERM-with-grace so cookies flush to disk first."""
751
+ return json.dumps(stop_chrome(), indent=2)
752
+
753
+
754
+ @mcp.tool()
755
+ def bh_restart() -> str:
756
+ """Flush + restart the managed Chrome in one step. Gracefully stops it (so
757
+ Chrome persists the cookie store to disk and any port-orphan is reaped), then
758
+ starts a fresh instance that loads the just-flushed session from disk. Use
759
+ this instead of killing Chrome by hand — a hard kill drops the in-memory X
760
+ session before it is written, which is what forces a re-login."""
761
+ stopped = stop_chrome()
762
+ time.sleep(0.5)
763
+ started = ensure_chrome()
764
+ return json.dumps({"status": "restarted", "stopped": stopped, "started": started}, indent=2)
765
+
766
+
767
+ @mcp.tool()
768
+ def bh_seed_cookies(source: str = "chrome:Default", domains: str | None = None) -> str:
769
+ """Import cookies from a local browser profile into the managed Chrome.
770
+
771
+ Shells out to ai_browser_profile.cookies (~/ai-browser-profile/.venv).
772
+ Reads cookies from the source profile via macOS Keychain + AES-CBC decrypt,
773
+ then injects via CDP Storage.setCookies into our managed Chrome on PORT.
774
+
775
+ macOS-only at present (depends on Keychain). On Linux this returns an
776
+ error pointing to a manual cookie-injection path.
777
+
778
+ Args:
779
+ source: 'browser:profile' spec, e.g. 'chrome:Profile 1', 'arc:Default'.
780
+ Browsers: chrome, arc, brave, edge.
781
+ domains: comma-separated host_key substrings to filter
782
+ (e.g. 'github.com,linear.app'). None = ALL cookies. Highly
783
+ recommended to filter — cookies are auth secrets and importing
784
+ everything mirrors your full session into the managed browser.
785
+
786
+ Returns JSON with ok, returncode, stdout (cookie counts only — never values), stderr.
787
+ """
788
+ if _IS_LINUX:
789
+ return json.dumps(
790
+ {
791
+ "ok": False,
792
+ "error": (
793
+ "bh_seed_cookies is macOS-only (depends on Keychain). "
794
+ "On Linux, log in to the target site once in the managed "
795
+ "Chrome (the profile at ~/.claude/browser-profiles/browser-harness "
796
+ "persists), or inject cookies via your own bootstrap."
797
+ ),
798
+ },
799
+ indent=2,
800
+ )
801
+ ensure_chrome()
802
+ abp_python = Path.home() / "ai-browser-profile" / ".venv" / "bin" / "python"
803
+ if not abp_python.exists():
804
+ return json.dumps(
805
+ {"ok": False, "error": f"ai-browser-profile venv not found at {abp_python}"},
806
+ indent=2,
807
+ )
808
+
809
+ cmd = [
810
+ str(abp_python),
811
+ "-m",
812
+ "ai_browser_profile.cookies",
813
+ "copy",
814
+ "--from",
815
+ source,
816
+ "--to",
817
+ CDP_URL,
818
+ ]
819
+ if domains:
820
+ cmd += ["--domains", domains]
821
+
822
+ try:
823
+ proc = subprocess.run(
824
+ cmd,
825
+ capture_output=True,
826
+ text=True,
827
+ timeout=60,
828
+ cwd=str(Path.home() / "ai-browser-profile"),
829
+ )
830
+ except subprocess.TimeoutExpired:
831
+ return json.dumps({"ok": False, "error": "seed timed out after 60s"}, indent=2)
832
+
833
+ return json.dumps(
834
+ {
835
+ "ok": proc.returncode == 0,
836
+ "returncode": proc.returncode,
837
+ "stdout": proc.stdout,
838
+ "stderr": proc.stderr,
839
+ },
840
+ indent=2,
841
+ )
842
+
843
+
844
+ @mcp.tool()
845
+ def bh_seed_localstorage(source: str = "chrome:Default", origins: str | None = None) -> str:
846
+ """Import localStorage from a local browser profile into the managed Chrome.
847
+
848
+ Sister to bh_seed_cookies. macOS-only (same Keychain dependency).
849
+
850
+ Args:
851
+ source: 'browser:profile' spec, e.g. 'chrome:Profile 1'.
852
+ origins: comma-separated host substrings (e.g. 'chatgpt.com,notion.so').
853
+
854
+ Returns JSON with ok, returncode, stdout (counts only, no values), stderr.
855
+ """
856
+ if _IS_LINUX:
857
+ return json.dumps(
858
+ {
859
+ "ok": False,
860
+ "error": (
861
+ "bh_seed_localstorage is macOS-only (depends on Keychain). "
862
+ "On Linux, log in once in the managed Chrome."
863
+ ),
864
+ },
865
+ indent=2,
866
+ )
867
+ ensure_chrome()
868
+ abp_python = Path.home() / "ai-browser-profile" / ".venv" / "bin" / "python"
869
+ if not abp_python.exists():
870
+ return json.dumps(
871
+ {"ok": False, "error": f"ai-browser-profile venv not found at {abp_python}"},
872
+ indent=2,
873
+ )
874
+
875
+ cmd = [
876
+ str(abp_python),
877
+ "-m",
878
+ "ai_browser_profile.localstorage",
879
+ "copy",
880
+ "--from",
881
+ source,
882
+ "--to",
883
+ CDP_URL,
884
+ ]
885
+ if origins:
886
+ cmd += ["--origins", origins]
887
+
888
+ try:
889
+ proc = subprocess.run(
890
+ cmd,
891
+ capture_output=True,
892
+ text=True,
893
+ timeout=180, # tab-per-origin can take a while
894
+ cwd=str(Path.home() / "ai-browser-profile"),
895
+ )
896
+ except subprocess.TimeoutExpired:
897
+ return json.dumps({"ok": False, "error": "seed timed out after 180s"}, indent=2)
898
+
899
+ return json.dumps(
900
+ {
901
+ "ok": proc.returncode == 0,
902
+ "returncode": proc.returncode,
903
+ "stdout": proc.stdout,
904
+ "stderr": proc.stderr,
905
+ },
906
+ indent=2,
907
+ )
908
+
909
+
910
+ @mcp.tool()
911
+ def bh_screenshot(quality: int = 50) -> str:
912
+ """Capture a screenshot of the current tab and write it to a temp file.
913
+
914
+ Returns JSON with the file path and basic page info. Use bh_run for any
915
+ workflow that needs to keep state across multiple steps.
916
+
917
+ The `quality` parameter is accepted for back-compat but is ignored — the
918
+ current upstream `capture_screenshot()` signature is (path, full, max_dim)
919
+ and does not expose a JPEG-quality knob. Older callers (and the MCP tool
920
+ schema) still pass it; we just don't forward it.
921
+ """
922
+ # Avoid the unused-variable lint and keep `quality` part of the MCP
923
+ # contract: a sanity-cap so a bad caller can't pass arbitrary types.
924
+ _ = int(quality)
925
+ script = (
926
+ "import json, time, os\n"
927
+ "ensure_real_tab()\n"
928
+ "info = page_info()\n"
929
+ "path = capture_screenshot()\n"
930
+ "print(json.dumps({\"screenshot\": str(path), \"page\": info}))\n"
931
+ )
932
+ result = _run_harness(script)
933
+ if not result.get("ok"):
934
+ return json.dumps(result, indent=2)
935
+ # Last line of stdout is our JSON.
936
+ out = (result.get("stdout") or "").strip().splitlines()
937
+ payload = out[-1] if out else "{}"
938
+ return payload
939
+
940
+
941
+ @mcp.tool()
942
+ def bh_navigate(url: str, new_tab: bool = True) -> str:
943
+ """Open a URL. By default opens in a fresh tab (recommended)."""
944
+ if new_tab:
945
+ nav = f"new_tab({url!r})"
946
+ else:
947
+ nav = f"ensure_real_tab(); goto_url({url!r})"
948
+ script = (
949
+ "import json\n"
950
+ f"{nav}\n"
951
+ "wait_for_load()\n"
952
+ "info = page_info()\n"
953
+ "print(json.dumps(info))\n"
954
+ )
955
+ result = _run_harness(script)
956
+ if not result.get("ok"):
957
+ return json.dumps(result, indent=2)
958
+ out = (result.get("stdout") or "").strip().splitlines()
959
+ return out[-1] if out else "{}"
960
+
961
+
962
+ if __name__ == "__main__":
963
+ _log("server starting")
964
+ try:
965
+ mcp.run()
966
+ except Exception as e:
967
+ _log(f"server crashed: {e!r}")
968
+ raise